Compare commits

..

11 Commits

Author SHA1 Message Date
Gauvain
a58a4da4f3 fix(subtitles): guard track-list effect against stale async runs
The track-building effect in VideoContext reruns once api?.basePath and
isCurrentSubImageBased settle. An earlier async run could resolve after a
rerun and overwrite subtitleTracks/audioTracks with setTrack callbacks bound
to a stale `api`, breaking external-subtitle identity matching.

Add a cancellation token and route every state commit through guarded
committers so all six commit points (offline-transcoded audio/subs,
burned-in, and the online audio/subs paths) drop writes from a dead run,
plus bail out right after the awaited getAudioTracks when cancelled.
2026-06-30 01:56:58 +02:00
Gauvain
c02baf2831 fix(subtitles): rebuild track callbacks when isCurrentSubImageBased changes
The setTrack/Disable callbacks close over isCurrentSubImageBased for the
transcode replacePlayer decision; add it to the track-building effect deps so
they rebuild when it flips (otherwise, in a transcoding session, callbacks could
stay on the MPV path after switching to/from a burned-in image sub and the
player would not refresh). Addresses CodeRabbit.
2026-06-30 01:41:21 +02:00
Gauvain
3848877021 fix(subtitles): only show the burned-in entry for image subs in offline transcoded
When an image subtitle was burned into a transcoded download it lives in the
video pixels and can't be disabled or swapped. Show only that '(burned in)'
entry instead of advertising Disable/text controls whose handlers can't affect
it (which would let the UI show a different selection than what's on screen).
Addresses CodeRabbit.
2026-06-30 01:32:43 +02:00
Gauvain
1f54ccc52c fix(subtitles): rebuild VideoContext track callbacks when api.basePath changes
The setTrack callbacks build external-sub URLs from api?.basePath; add it to the
track-building effect deps so the list rebuilds once the API is ready (otherwise
online externals could resolve with undefined → notFound). Addresses CodeRabbit.
2026-06-30 01:19:18 +02:00
Gauvain
08efa1b0f7 Merge branch 'develop' into fix/subtitle-track-selection 2026-06-30 01:10:59 +02:00
Gauvain
90ea934548 fix(subtitles): address CodeRabbit review
- Unify external detection: isExternalSubtitle drops the bare-DeliveryUrl case
  (an Hls-delivered sub has a DeliveryUrl but isn't sub-add-ed) so sorting,
  loading and resolution agree; compareTracksForMenu now uses it.
- applyMpvSubtitleSelection wraps player calls in try/catch — fire-and-forget
  call sites no longer risk unhandled rejections.
- VideoContext offline-transcoded branch: treat missing IsTextSubtitleStream as
  text (use !isImageBasedSubtitle), matching the shared helper.
- ItemContent.tv refreshSubtitleTracks: apply compareTracksForMenu like the
  initial list.
- Tests: use the @/ alias; rework the embedded cases to actually exercise
  identity (reversed player order) and the ordinal fallback (same-language,
  no title).
2026-06-30 01:08:50 +02:00
Gauvain
1c158dea4e fix(subtitles): order detail-page & TV subtitle menus like jellyfin-web
The detail-page selector (MediaSourceButton, the #1176 replacement for
TrackSheet) and the TV detail/refresh paths (ItemContent.tv, Controls.tv
refreshSubtitleTracks) still listed subtitles in raw MediaStreams order
(externals first). Apply compareTracksForMenu there too so every menu
matches web. The in-player TV modal was already covered (fed from the
sorted VideoContext tracks).
2026-06-30 00:39:29 +02:00
Gauvain
9a7b9c9de2 fix(subtitles): select subtitles by identity across all player paths
direct-player resolves the selection on onTracksReady (online + offline, init +
runtime), VideoContext does the same for the mobile menu (incl. offline-transcoded),
and the menus (SubtitleTrackSelector, VideoContext, TVSubtitleSheet) now order
tracks like jellyfin-web. Fixes wrong-subtitle/wrong-language selection.

Fixes #954
2026-06-30 00:11:45 +02:00
Gauvain
ceeacda7f9 feat(subtitles): identity-based track resolver + jellyfin-web menu order
resolveSubtitleTrack matches a Jellyfin subtitle against the player's real
track list (external by external-filename, embedded by language/title) instead
of positional counting, which mis-selects when externals/embedded are reordered
or the server hides embedded subs. applyMpvSubtitleSelection is the shared entry
point (reusable for the cast backend). compareTracksForMenu mirrors web's
itemHelper.sortTracks. Drops dead getMpvSubtitleId/isSubtitleInMpv. 14 unit tests.
2026-06-30 00:11:12 +02:00
Gauvain
b8780f34ec feat(mpv): expose external/external-filename/ff-index/codec on subtitle track-list
These identity fields let the JS layer map a Jellyfin subtitle to the real
MPV track instead of guessing a positional sid.
2026-06-30 00:10:57 +02:00
Gauvain
97b6a912e0 refactor: migrate JS/MJS sources and scripts to TypeScript (#1717)
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
2026-06-29 19:48:32 +02:00
46 changed files with 947 additions and 620 deletions

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards
- Use TypeScript for ALL files (no .js files)
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management

View File

@@ -11,7 +11,7 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.

View File

@@ -30,7 +30,7 @@ jobs:
bun-version: "1.3.14"
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
run: bun scripts/detect-duplicate-issue.ts
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}

4
.gitignore vendored
View File

@@ -12,10 +12,6 @@ web-build/
# Platform-specific Build Directories
/ios
/android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards
- Use TypeScript for all files (no .js)
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

View File

@@ -1,13 +1,9 @@
// Registers the tsx require hook so the TypeScript config plugins referenced
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
import "tsx/cjs";
import { execFileSync } from "node:child_process";
import type { ConfigContext, ExpoConfig } from "expo/config";
const { execFileSync } = require("node:child_process");
// Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args: string[]): string | null => {
const git = (args) => {
try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
@@ -46,16 +42,16 @@ const buildMeta = {
builtAt: new Date().toISOString(),
};
export default ({ config }: ConfigContext): ExpoConfig => {
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
config.plugins?.push("expo-background-task");
config.plugins.push("expo-background-task");
config.plugins?.push([
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins?.push([
config.plugins.push([
"expo-camera",
{
cameraPermission:
@@ -65,7 +61,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
}
// Only override googleServicesFile if env var is set
const androidConfig: { googleServicesFile?: string } = {};
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
@@ -75,5 +71,5 @@ export default ({ config }: ConfigContext): ExpoConfig => {
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
} as ExpoConfig;
};
};

View File

@@ -71,8 +71,8 @@
],
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.ts",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[
"expo-build-properties",
{
@@ -134,17 +134,17 @@
}
],
"expo-web-browser",
["./plugins/with-runtime-framework-headers.ts"],
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
["./plugins/withAndroidAlertColors.ts"],
["./plugins/withAndroidManifest.ts"],
["./plugins/withTrustLocalCerts.ts"],
["./plugins/withGradleProperties.ts"],
["./plugins/withTVOSAppIcon.ts"],
["./plugins/withTVOSTopShelf.ts"],
["./plugins/withTVXcodeEnv.ts"],
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
[
"./plugins/withGitPod.ts",
"./plugins/withGitPod.js",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -56,8 +56,8 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
applyMpvSubtitleSelection,
getMpvAudioId,
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { msToTicks, ticksToSeconds } from "@/utils/time";
@@ -639,12 +639,9 @@ export default function DirectPlayerPage() {
).map((s) => s.DeliveryUrl!);
}
// Calculate track IDs for initial selection
const initialSubtitleId = getMpvSubtitleId(
mediaSource,
subtitleIndex,
isTranscoding,
);
// Audio maps positionally (audio tracks aren't reordered or hidden like
// subtitles). The subtitle selection is applied later, once MPV's real track
// list is known — see applySubtitleSelection / onTracksReady.
const initialAudioId = getMpvAudioId(
mediaSource,
audioIndex,
@@ -662,7 +659,6 @@ export default function DirectPlayerPage() {
url: stream.url,
startPosition: startPos,
autoplay: true,
initialSubtitleId,
initialAudioId,
// Pass cache/buffer settings from user preferences
cacheConfig: {
@@ -710,7 +706,6 @@ export default function DirectPlayerPage() {
playbackPositionFromUrl,
api?.basePath,
api?.accessToken,
subtitleIndex,
audioIndex,
offline,
settings.mpvCacheEnabled,
@@ -908,30 +903,41 @@ export default function DirectPlayerPage() {
);
// TV subtitle track change handler
/**
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
* it. Identity-based (external by filename, embedded by language/title) so it
* stays correct across external/embedded reordering and server-hidden embedded
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
* fired again after each external sub-add) and runtime changes.
*/
const applySubtitleSelection = useCallback(
async (jellyfinSubtitleIndex: number) => {
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
await applyMpvSubtitleSelection(videoRef.current, {
subtitleStreams,
jellyfinSubtitleIndex,
// The exact URL each external sub was loaded into MPV with — mirrors the
// externalSubtitles array built in videoSource (online: basePath +
// DeliveryUrl, offline: local DeliveryUrl).
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath ? `${api.basePath}${s.DeliveryUrl}` : undefined;
},
});
},
[stream?.mediaSource, offline, api?.basePath],
);
// TV/mobile subtitle track change handler
const handleSubtitleIndexChange = useCallback(
async (index: number) => {
setCurrentSubtitleIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
if (index === -1) {
// Disable subtitles
await videoRef.current?.disableSubtitles?.();
} else {
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvSubtitleId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
}
}
await applySubtitleSelection(index);
},
[stream?.mediaSource],
[applySubtitleSelection],
);
// Technical info toggle handler
@@ -1296,6 +1302,10 @@ export default function DirectPlayerPage() {
}}
onTracksReady={() => {
setTracksReady(true);
// Fired after embedded tracks enumerate and again after each
// external sub-add; re-resolve so the final fire (full track
// list) selects the right track by identity.
void applySubtitleSelection(currentSubtitleIndex);
}}
/>
{!hasPlaybackStarted && (

View File

@@ -113,7 +113,6 @@
"husky": "9.1.7",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3",
},
},
@@ -295,58 +294,6 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
@@ -961,8 +908,6 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1863,8 +1808,6 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],

View File

@@ -56,6 +56,7 @@ import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -232,12 +233,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Get available subtitle tracks (raw MediaStream[] for label lookup)
// Get available subtitle tracks (raw MediaStream[] for label lookup),
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
const subtitleStreams = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
return streams ?? [];
return streams ? [...streams].sort(compareTracksForMenu) : [];
}, [selectedOptions?.mediaSource]);
// Store handleSubtitleChange in a ref for stable callback reference
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)
: freshItem.MediaSources?.[0];
// Get subtitle streams from the fresh data
const streams =
mediaSource?.MediaStreams?.filter(
// Get subtitle streams from the fresh data, ordered like jellyfin-web
// (embedded first, externals last) — same as the initial list.
const streams = [
...(mediaSource?.MediaStreams?.filter(
(s: MediaStream) => s.Type === "Subtitle",
) ?? [];
) ?? []),
].sort(compareTracksForMenu);
// Convert to Track[] with setTrack callbacks
const tracks: Track[] = streams.map((stream) => ({

View File

@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
const subtitleStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || [],
// Order like jellyfin-web (embedded first, externals last, forced/default up).
[
...(selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || []),
].sort(compareTracksForMenu),
[selectedOptions.mediaSource],
);

View File

@@ -2,6 +2,7 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { tc } from "@/utils/textTools";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const [open, setOpen] = useState(false);
const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
// Order like jellyfin-web (embedded first, externals last, forced/default up).
return subs ? [...subs].sort(compareTracksForMenu) : subs;
}, [source]);
const selectedSubtitleSteam = useMemo(

View File

@@ -51,6 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext";
@@ -317,8 +318,10 @@ export const Controls: FC<Props> = ({
try {
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
// Skip streams without a real index: `?? -1` would alias them to the
// "disable subtitles" sentinel and mis-route selection.
return streams
// "disable subtitles" sentinel and mis-route selection. Order like
// jellyfin-web (embedded first, externals last, forced/default up).
return [...streams]
.sort(compareTracksForMenu)
.filter((stream) => typeof stream.Index === "number")
.map((stream) => {
const index = stream.Index as number;

View File

@@ -33,6 +33,7 @@ import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
interface TVSubtitleSheetProps {
@@ -96,13 +97,19 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
// Order like jellyfin-web (embedded first, externals last, forced/default up).
const sortedTracks = useMemo(
() => [...subtitleTracks].sort(compareTracksForMenu),
[subtitleTracks],
);
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0;
const trackIdx = subtitleTracks.findIndex(
const trackIdx = sortedTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [subtitleTracks, currentSubtitleIndex]);
}, [sortedTracks, currentSubtitleIndex]);
useEffect(() => {
if (visible) {
@@ -215,7 +222,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
value: -1,
selected: currentSubtitleIndex === -1,
};
const options = subtitleTracks.map((track) => ({
const options = sortedTracks.map((track) => ({
label:
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
sublabel: track.Codec?.toUpperCase(),
@@ -223,7 +230,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
selected: track.Index === currentSubtitleIndex,
}));
return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t]);
}, [sortedTracks, currentSubtitleIndex, t]);
if (!visible) return null;

View File

@@ -23,32 +23,29 @@
* - Used to report playback state to Jellyfin server
* - Value of -1 means disabled/none
*
* 2. MPV INDEX (track.mpvIndex)
* - MPV's internal track ID
* - MPV orders tracks as: [all embedded, then all external]
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
* 2. PLAYER TRACK (selected by IDENTITY, not position)
* - Selection resolves the server Index against MPV's REAL track list via
* applyMpvSubtitleSelection: externals matched by external-filename,
* embedded by language/title. `track.mpvIndex` is no longer used to select
* (kept -1) — positional mapping mis-selected when externals/embedded were
* reordered or the server hid embedded subs (#954 et al.).
*
* ============================================================================
* SUBTITLE HANDLING
* ============================================================================
*
* Embedded (DeliveryMethod.Embed):
* - Already in MPV's track list
* - Select via setSubtitleTrack(mpvId)
*
* External (DeliveryMethod.External):
* - Loaded into MPV on video start
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
* Embedded & External:
* - Selected via applyMpvSubtitleSelection (identity match against the live
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
* embedded first, externals last, forced/default float up).
*
* Image-based during transcoding:
* - Burned into video by Jellyfin, not in MPV
* - Requires replacePlayer() to change
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
*/
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
import { File } from "expo-file-system";
import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import type React from "react";
import {
createContext,
@@ -61,9 +58,14 @@ import {
import { Platform } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import type { MpvAudioTrack } from "@/modules";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import {
applyMpvSubtitleSelection,
compareTracksForMenu,
isImageBasedSubtitle,
} from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
@@ -87,6 +89,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
const playerControls = usePlayerControls();
const offline = useOfflineMode();
const api = useAtomValue(apiAtom);
const router = useRouter();
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
@@ -141,6 +144,19 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!tracksReady) return;
// Guard every state commit against stale runs: api?.basePath /
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
// earlier async run (which captured an old `api`) must not finish later and
// overwrite the fresh track list with callbacks bound to stale closures.
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
let cancelled = false;
const commitSubtitleTracks = (next: Track[]) => {
if (!cancelled) setSubtitleTracks(next);
};
const commitAudioTracks = (next: Track[]) => {
if (!cancelled) setAudioTracks(next);
};
const fetchTracks = async () => {
// Check if this is offline transcoded content
// For transcoded offline content, only ONE audio track exists in the file
@@ -166,10 +182,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
},
},
];
setAudioTracks(audio);
commitAudioTracks(audio);
} else {
// Fallback: show no audio tracks if the stored track wasn't found
setAudioTracks([]);
commitAudioTracks([]);
}
// For subtitles in transcoded offline content:
@@ -179,6 +195,24 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
downloadedItem.userData.subtitleStreamIndex;
const subs: Track[] = [];
// If an IMAGE subtitle was burned into the transcoded download it's in the
// video pixels — it can't be turned off or swapped. Show only that entry
// instead of advertising "Disable"/text controls that can't affect it.
const burnedInSub = allSubs.find(
(s) => s.Index === downloadedSubtitleIndex,
);
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
commitSubtitleTracks([
{
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
index: burnedInSub.Index ?? -1,
mpvIndex: -1,
setTrack: () => {},
},
]);
return;
}
// Add "Disable" option
subs.push({
name: "Disable",
@@ -190,123 +224,82 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
},
});
// For text-based subs, they should still be available in the file
let subIdx = 1;
for (const sub of allSubs) {
if (sub.IsTextSubtitleStream) {
// Text subs are muxed into the transcoded file and switchable; resolve by
// identity against MPV's real track list (same as online). Order matches web.
// Image subs aren't in the transcoded file (only the burned one was, handled
// above), so skip them here.
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
if (!isImageBasedSubtitle(sub)) {
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: subIdx,
mpvIndex: -1,
setTrack: () => {
playerControls.setSubtitleTrack(subIdx);
router.setParams({ subtitleIndex: String(sub.Index) });
},
});
subIdx++;
} else if (sub.Index === downloadedSubtitleIndex) {
// This image-based sub was burned in - show it but indicate it's active
subs.push({
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
index: sub.Index ?? -1,
mpvIndex: -1, // Can't be changed
setTrack: () => {
// Already burned in, just update params
router.setParams({ subtitleIndex: String(sub.Index) });
void applyMpvSubtitleSelection(playerControls, {
subtitleStreams: allSubs,
jellyfinSubtitleIndex: sub.Index ?? -1,
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath
? `${api.basePath}${s.DeliveryUrl}`
: undefined;
},
});
},
});
}
}
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
commitSubtitleTracks(subs);
return;
}
// MPV track handling
const audioData = await playerControls.getAudioTracks().catch(() => null);
if (cancelled) return;
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
// Separate embedded vs external subtitles from Jellyfin's list
// MPV orders tracks as: [all embedded, then all external]
const embeddedSubs = allSubs.filter(
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
);
const externalSubs = allSubs.filter(
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
);
// Count embedded subs that will be in MPV
// (excludes image-based subs during transcoding as they're burned in)
const embeddedInPlayer = embeddedSubs.filter(
(s) => !isTranscoding || !isImageBasedSubtitle(s),
);
const subs: Track[] = [];
// Process all Jellyfin subtitles
for (const sub of allSubs) {
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
const isExternal =
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
// For image-based subs during transcoding, need to refresh player
if (isTranscoding && isImageBasedSubtitle(sub)) {
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: -1,
setTrack: () => {
replacePlayer({ subtitleIndex: String(sub.Index) });
},
});
continue;
}
// Calculate MPV track ID based on type
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
let mpvId = -1;
if (isEmbedded) {
// Find position among embedded subs that are in player
const embeddedPosition = embeddedInPlayer.findIndex(
(s) => s.Index === sub.Index,
);
if (embeddedPosition !== -1) {
mpvId = embeddedPosition + 1; // 1-based ID
}
} else if (isExternal) {
// Find position among external subs, offset by embedded count
const externalPosition = externalSubs.findIndex(
(s) => s.Index === sub.Index,
);
if (externalPosition !== -1) {
mpvId = embeddedInPlayer.length + externalPosition + 1;
}
}
// Process all Jellyfin subtitles. Selection resolves against MPV's real
// track list by identity (applyMpvSubtitleSelection) — never positional
// index math, which mis-selects across external/embedded reordering and
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
// Image-based subs during transcoding are burned into the video by the
// server; both switching TO one and switching AWAY from a currently
// active one require a player refresh (re-transcode), not a track change.
const needsReplace =
isTranscoding &&
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: mpvId,
mpvIndex: -1,
setTrack: () => {
// Transcoding + switching to/from image-based sub
if (
isTranscoding &&
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
) {
if (needsReplace) {
replacePlayer({ subtitleIndex: String(sub.Index) });
return;
}
// Direct switch in player
if (mpvId !== -1) {
playerControls.setSubtitleTrack(mpvId);
router.setParams({ subtitleIndex: String(sub.Index) });
return;
}
// Fallback - refresh player
replacePlayer({ subtitleIndex: String(sub.Index) });
router.setParams({ subtitleIndex: String(sub.Index) });
void applyMpvSubtitleSelection(playerControls, {
subtitleStreams: allSubs,
jellyfinSubtitleIndex: sub.Index ?? -1,
// Mirror how external subs are loaded into MPV (online: basePath +
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
// external-filename lines up.
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath
? `${api.basePath}${s.DeliveryUrl}`
: undefined;
},
});
},
});
}
@@ -374,12 +367,29 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
}
}
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
setAudioTracks(audio);
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
// at the front (unshifted), local downloaded subs at the end.
commitSubtitleTracks(subs);
commitAudioTracks(audio);
};
fetchTracks();
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
return () => {
cancelled = true;
};
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
// API is ready so online externals don't resolve with undefined.
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
// decision — rebuild when it flips so we refresh the stream when we should.
}, [
tracksReady,
mediaSource,
offline,
downloadedItem,
itemId,
api?.basePath,
isCurrentSubImageBased,
]);
return (
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>

View File

@@ -3,9 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export default {
const MediaTypes = {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
};
} as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

View File

@@ -532,16 +532,27 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
val track = mutableMapOf<String, Any>("id" to trackId)
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
// Identity fields used to map a Jellyfin subtitle to the real track
// (instead of fragile positional counting). `external` + `external-filename`
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
track["external"] = external
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
track["externalFilename"] = it
}
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected
tracks.add(track)
}
return tracks
}

View File

@@ -770,12 +770,32 @@ final class MPVLayerRenderer {
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
track["lang"] = lang
}
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
track["codec"] = codec
}
// Identity fields used to map a Jellyfin subtitle to the real track
// (instead of fragile positional counting). `external` + `external-filename`
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
var external: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/external", format: MPV_FORMAT_FLAG, value: &external)
track["external"] = external != 0
if let extFilename = getStringProperty(handle: handle, name: "track-list/\(i)/external-filename") {
track["externalFilename"] = extFilename
}
var ffIndex: Int64 = 0
if getProperty(handle: handle, name: "track-list/\(i)/ff-index", format: MPV_FORMAT_INT64, value: &ffIndex) >= 0 {
track["ffIndex"] = Int(ffIndex)
}
var selected: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
track["selected"] = selected != 0
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
tracks.append(track)
}

View File

@@ -141,6 +141,14 @@ export type SubtitleTrack = {
id: number;
title?: string;
lang?: string;
/** Subtitle codec (mpv `codec`), e.g. "subrip", "ass", "hdmv_pgs_subtitle". */
codec?: string;
/** True if loaded from a separate file via `sub-add` (mpv `external`). */
external?: boolean;
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
externalFilename?: string;
/** FFmpeg stream index (mpv `ff-index`); not guaranteed for non-lavf demuxers. */
ffIndex?: number;
selected?: boolean;
};

View File

@@ -17,13 +17,13 @@
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky",
"typecheck": "node scripts/typecheck.js",
"typecheck": "bun scripts/typecheck.ts",
"check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"i18n:check": "bun scripts/check-i18n-keys.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package"
},
@@ -136,7 +136,6 @@
"husky": "9.1.7",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3"
},
"expo": {

View File

@@ -1,4 +1,4 @@
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const { withPodfile } = require("expo/config-plugins");
const PATCH_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< runtime-framework headers";
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
];
function buildPatch(): string {
function buildPatch() {
return [
PATCH_START,
" extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch(): string {
].join("\n");
}
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
module.exports = function withRuntimeFrameworkHeaders(config) {
return withPodfile(config, (config) => {
let podfile = config.modResults.contents;
@@ -125,5 +125,3 @@ end
return config;
});
};
export default withRuntimeFrameworkHeaders;

View File

@@ -1,20 +1,10 @@
import {
type ConfigPlugin,
const {
withAndroidColors,
withAndroidColorsNight,
} from "expo/config-plugins";
} = require("expo/config-plugins");
interface ColorResourceItem {
$: { name: string };
_: string;
}
const withAndroidAlertColors: ConfigPlugin = (config) => {
const setColor = (
colorsList: ColorResourceItem[],
name: string,
value: string,
) => {
const withAndroidAlertColors = (config) => {
const setColor = (colorsList, name, value) => {
const existingColor = colorsList.find(
(item) => item.$ && item.$.name === name,
);
@@ -30,7 +20,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
config = withAndroidColors(config, (config) => {
const colors = config.modResults;
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
const colorsList = colors.resources.color || [];
setColor(colorsList, "colorPrimary", "#000000");
colors.resources.color = colorsList;
return config;
@@ -38,7 +28,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
config = withAndroidColorsNight(config, (config) => {
const colors = config.modResults;
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
const colorsList = colors.resources.color || [];
setColor(colorsList, "colorPrimary", "#FFFFFF");
colors.resources.color = colorsList;
return config;
@@ -47,4 +37,4 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
return config;
};
export default withAndroidAlertColors;
module.exports = withAndroidAlertColors;

View File

@@ -1,12 +1,8 @@
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
const { withAndroidManifest } = require("expo/config-plugins");
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
const _withGoogleCastAndroidManifest = (config) =>
withAndroidManifest(config, async (mod) => {
const mainApplication = mod.modResults.manifest.application?.[0];
if (!mainApplication) {
return mod;
}
const mainApplication = mod.modResults.manifest.application[0];
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
@@ -43,4 +39,4 @@ const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
return mod;
});
export default withGoogleCastAndroidManifest;
module.exports = _withGoogleCastAndroidManifest;

View File

@@ -1,8 +1,8 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const { readFileSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
const { withDangerousMod } = require("expo/config-plugins");
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
withDangerousMod(expoConfig, [
"android",
(modConfig) => {
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
},
]);
export default withChangeNativeAndroidTextToWhite;
module.exports = withChangeNativeAndroidTextToWhite;

View File

@@ -1,6 +1,6 @@
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
const { withAppBuildGradle } = require("expo/config-plugins");
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
module.exports = function withExcludeMedia3Dash(config) {
return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents;
@@ -32,5 +32,3 @@ configurations.all {
return config;
});
};
export default withExcludeMedia3Dash;

View File

@@ -1,14 +1,6 @@
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const { withPodfile } = require("@expo/config-plugins");
interface GitPodOptions {
podName: string;
podspecUrl: string;
}
const withGitPod: ConfigPlugin<GitPodOptions> = (
config,
{ podName, podspecUrl },
) => {
const withGitPod = (config, { podName, podspecUrl }) => {
return withPodfile(config, (config) => {
const podfile = config.modResults.contents;
@@ -29,4 +21,4 @@ const withGitPod: ConfigPlugin<GitPodOptions> = (
});
};
export default withGitPod;
module.exports = withGitPod;

View File

@@ -1,21 +1,12 @@
import type { ExpoConfig } from "expo/config";
import {
AndroidConfig,
type ConfigPlugin,
withGradleProperties,
} from "expo/config-plugins";
const { withGradleProperties } = require("expo/config-plugins");
function setGradlePropertiesValue(
config: ExpoConfig,
key: string,
value: string,
): ExpoConfig {
function setGradlePropertiesValue(config, key, value) {
return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults;
const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key,
);
const property: AndroidConfig.Properties.PropertiesItem = {
const property = {
type: "property",
key,
value,
@@ -31,7 +22,7 @@ function setGradlePropertiesValue(
});
}
const withCustomGradleProperties: ConfigPlugin = (config) => {
module.exports = function withCustomPlugin(config) {
// Expo 52 is not setting this
// https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
@@ -47,5 +38,3 @@ const withCustomGradleProperties: ConfigPlugin = (config) => {
);
return config;
};
export default withCustomGradleProperties;

View File

@@ -1,6 +1,6 @@
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
const { withXcodeProject } = require("@expo/config-plugins");
const withTVOSAppIcon: ConfigPlugin = (config) => {
const withTVOSAppIcon = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -28,4 +28,4 @@ const withTVOSAppIcon: ConfigPlugin = (config) => {
});
};
export default withTVOSAppIcon;
module.exports = withTVOSAppIcon;

View File

@@ -1,10 +1,8 @@
import type { ExpoConfig } from "expo/config";
import {
type ConfigPlugin,
const {
withEntitlementsPlist,
withInfoPlist,
withXcodeProject,
} from "expo/config-plugins";
} = require("@expo/config-plugins");
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
@@ -12,29 +10,19 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
"StreamyfinKeychainAccessGroupIdentifier";
interface AppExtensionConfig {
targetName: string;
bundleIdentifier: string;
entitlements: {
"com.apple.security.application-groups": string[];
"keychain-access-groups": string[];
};
}
function getBundleIdentifier(config: ExpoConfig): string {
function getBundleIdentifier(config) {
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
}
function getAppGroupIdentifier(config: ExpoConfig): string {
function getAppGroupIdentifier(config) {
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
}
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
function getKeychainAccessGroupIdentifier(config) {
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
}
// The xcode project object has no usable typings — keep `any` here.
function getBuildConfigurations(project: any, configurationListId: string) {
function getBuildConfigurations(project, configurationListId) {
const configurationList =
project.hash.project.objects.XCConfigurationList[configurationListId];
@@ -42,21 +30,18 @@ function getBuildConfigurations(project: any, configurationListId: string) {
const buildConfigurations = project.pbxXCBuildConfigurationSection();
return configurationList.buildConfigurations
.map((config: { value: string }) => buildConfigurations[config.value])
.map((config) => buildConfigurations[config.value])
.filter(Boolean);
}
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
function ensureAppGroup(value, appGroupIdentifier) {
const groups = Array.isArray(value) ? value : [];
return groups.includes(appGroupIdentifier)
? groups
: [...groups, appGroupIdentifier];
}
function ensureKeychainAccessGroup(
value: unknown,
keychainAccessGroupIdentifier: string,
): string[] {
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
const groups = Array.isArray(value) ? value : [];
return groups.includes(keychainAccessGroupIdentifier)
? groups
@@ -64,13 +49,13 @@ function ensureKeychainAccessGroup(
}
function ensureAppExtension(
appExtensions: unknown,
targetName: string,
bundleIdentifier: string,
appGroupIdentifier: string,
keychainAccessGroupIdentifier: string,
): AppExtensionConfig[] {
const extensionConfig: AppExtensionConfig = {
appExtensions,
targetName,
bundleIdentifier,
appGroupIdentifier,
keychainAccessGroupIdentifier,
) {
const extensionConfig = {
targetName,
bundleIdentifier,
entitlements: {
@@ -78,9 +63,7 @@ function ensureAppExtension(
"keychain-access-groups": [keychainAccessGroupIdentifier],
},
};
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
? appExtensions
: [];
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
// Keep plugin runs idempotent and preserve unrelated app extension entries.
const existingIndex = extensions.findIndex(
(appExtension) => appExtension?.targetName === targetName,
@@ -95,7 +78,7 @@ function ensureAppExtension(
);
}
const withTVOSTopShelf: ConfigPlugin = (config) => {
const withTVOSTopShelf = (config) => {
const appGroupIdentifier = getAppGroupIdentifier(config);
const keychainAccessGroupIdentifier =
getKeychainAccessGroupIdentifier(config);
@@ -210,4 +193,4 @@ const withTVOSTopShelf: ConfigPlugin = (config) => {
});
};
export default withTVOSTopShelf;
module.exports = withTVOSTopShelf;

View File

@@ -1,9 +1,9 @@
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
const { withEntitlementsPlist } = require("expo/config-plugins");
/**
* Expo config plugin to add User Management entitlement for tvOS profile linking
*/
const withTVUserManagement: ConfigPlugin = (config) => {
const withTVUserManagement = (config) => {
// Only add for tvOS builds. The entitlement is restricted by Apple and must
// be present in the provisioning profile, so injecting it into mobile builds
// breaks signing ("Entitlement ... not found and could not be included in
@@ -24,4 +24,4 @@ const withTVUserManagement: ConfigPlugin = (config) => {
});
};
export default withTVUserManagement;
module.exports = withTVUserManagement;

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const { withDangerousMod } = require("@expo/config-plugins");
const { execSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
/**
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
@@ -12,7 +12,7 @@ import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
*
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
*/
const withTVXcodeEnv: ConfigPlugin = (config) => {
const withTVXcodeEnv = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -70,7 +70,7 @@ const withTVXcodeEnv: ConfigPlugin = (config) => {
/**
* Get the actual node binary path, handling nvm installations.
*/
function getNodeBinaryPath(): string | null {
function getNodeBinaryPath() {
try {
// First try to get node path directly (works for non-nvm installs)
const directPath = execSync("which node 2>/dev/null", {
@@ -114,4 +114,4 @@ function getNodeBinaryPath(): string | null {
return null;
}
export default withTVXcodeEnv;
module.exports = withTVXcodeEnv;

View File

@@ -1,29 +1,18 @@
import fs from "node:fs";
import path from "node:path";
import {
AndroidConfig,
type ConfigPlugin,
type ExportedConfigWithProps,
withAndroidManifest,
} from "expo/config-plugins";
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
const path = require("node:path");
const fs = require("node:fs");
const fsPromises = fs.promises;
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
const withTrustLocalCerts: ConfigPlugin = (config) => {
const withTrustLocalCerts = (config) => {
return withAndroidManifest(config, async (mod) => {
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
return mod;
});
};
async function setCustomConfigAsync(
config: ExportedConfigWithProps<AndroidManifest>,
androidManifest: AndroidManifest,
): Promise<AndroidManifest> {
async function setCustomConfigAsync(config, androidManifest) {
const src_file_path = path.join(__dirname, "network_security_config.xml");
const res_file_path = path.join(
await AndroidConfig.Paths.getResourceFolderAsync(
@@ -56,4 +45,4 @@ async function setCustomConfigAsync(
return androidManifest;
}
export default withTrustLocalCerts;
module.exports = withTrustLocalCerts;

View File

@@ -18,11 +18,11 @@
* - Edge cases the static scan cannot see can be allow-listed in the config file.
*
* Usage:
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.ts --json # machine-readable output
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/
import {
@@ -34,9 +34,20 @@ import {
} from "node:fs";
import { extname, join, relative } from "node:path";
type LocaleTree = { [key: string]: LocaleTree | string };
interface I18nConfig {
localesDir: string;
sourceLocale: string;
srcDirs: string[];
srcExtensions: string[];
excludeDirs: string[];
ignoreUnused: string[];
}
const ROOT = process.cwd();
const args = process.argv.slice(2);
const flag = (name, def) => {
const flag = (name: string, def: string | boolean): string | boolean => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def;
const [, v] = a.split("=");
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG = {
const DEFAULT_CONFIG: I18nConfig = {
localesDir: "translations",
sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [],
};
const config = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
const config: I18nConfig = existsSync(CONFIG_PATH)
? {
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
: DEFAULT_CONFIG;
// ---- helpers ----
const flatten = (obj, prefix = "", out = {}) => {
const flatten = (
obj: LocaleTree,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v;
else out[key] = v as string;
}
return out;
};
const globMatch = (key, pattern) => {
const globMatch = (key: string, pattern: string): boolean => {
if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern;
};
const walk = (dir, files = []) => {
let entries;
const walk = (dir: string, files: string[] = []): string[] => {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
}
for (const name of entries) {
const full = join(dir, name);
let st;
let st: ReturnType<typeof statSync>;
try {
st = statSync(full);
} catch {
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
// ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
);
const sourceKeySet = new Set(sourceKeys);
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set();
const fullyDynamic = []; // { file, line }
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set<string>();
const fullyDynamic: Array<{ file: string; line: number }> = [];
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact.
const stripComments = (src) =>
const stripComments = (src: string): string =>
src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key) =>
const literalUsed = (key: string): boolean =>
codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``);
const isUsed = (key) =>
const isUsed = (key: string): boolean =>
literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g));
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
// keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj, parts) => {
const removeKey = (obj: LocaleTree, parts: string[]): void => {
const [head, ...rest] = parts;
if (!(head in obj)) return;
if (rest.length === 0) {
delete obj[head];
return;
}
removeKey(obj[head], rest);
if (
obj[head] &&
typeof obj[head] === "object" &&
Object.keys(obj[head]).length === 0
)
delete obj[head];
const child = obj[head];
if (!child || typeof child !== "object") return;
removeKey(child, rest);
if (Object.keys(child).length === 0) delete obj[head];
};
if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
// the keys from them automatically on the next sync once they disappear from the source.
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log(
@@ -259,7 +274,7 @@ if (JSON_OUT) {
);
for (const k of unused) console.log(` - ${k}`);
console.log(
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
);
console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,

View File

@@ -21,8 +21,14 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
interface Issue {
number: number;
title: string;
body: string | null;
}
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
const numEnv = (name, def) => {
const numEnv = (name: string, def: number): number => {
const raw = process.env[name];
if (raw === undefined || raw === "") return def;
const n = Number(raw);
@@ -51,9 +57,9 @@ const STOP = new Set(
).split(/\s+/),
);
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s) =>
const tokens = (s: string | null): string[] =>
(s || "")
.toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -65,7 +71,7 @@ const tokens = (s) =>
.map(stem)
.filter((w) => w.length > 2);
const jaccard = (a, b) => {
const jaccard = (a: string[], b: string[]): number => {
const A = new Set(a);
const B = new Set(b);
if (!A.size || !B.size) return 0;
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
const newTitle = tokens(TITLE);
const newBody = tokens(BODY);
const score = (o) =>
const score = (o: Issue): number =>
0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself)
let issues;
let issues: Issue[];
if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
} else {
const raw = execFileSync(
"gh",
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
.map((l) => JSON.parse(l) as Issue);
}
const matches = issues
@@ -123,7 +129,7 @@ if (!matches.length) {
// Neutralise other issues' titles before echoing them back: break @mentions and
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) =>
const safeTitle = (t: string): string =>
(t || "")
.replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ")

View File

@@ -1,62 +0,0 @@
#!/usr/bin/env node
const _fs = require("node:fs");
const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd();
// const tvosPath = path.join(root, 'iostv');
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const paths = new Map([
["tvos", path.join(root, "iostv")],
["ios", path.join(root, "iosmobile")],
["android", path.join(root, "androidmobile")],
["androidtv", path.join(root, "androidtv")],
]);
// const platformPath = paths.get(platform);
if (isTV) {
stdout = execSync(
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
}
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }

View File

@@ -1,5 +1,8 @@
const { execFileSync } = require("node:child_process");
const process = require("node:process");
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import process from "node:process";
const require = createRequire(import.meta.url);
// Enhanced ANSI color codes and styles
const colors = {
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message, color = "") {
function log(message: string, color = "") {
if (useColor && color) {
console.log(`${color}${message}${colors.reset}`);
} else {
@@ -40,7 +43,7 @@ function log(message, color = "") {
}
}
function formatError(errorLine) {
function formatError(errorLine: string): string {
if (!useColor) return errorLine;
// Color file paths in cyan
@@ -70,12 +73,15 @@ function formatError(errorLine) {
return formatted;
}
function parseErrorsAndCreateSummary(errorOutput) {
function parseErrorsAndCreateSummary(errorOutput: string): {
formattedErrors: string[];
errorsByFile: Map<string, number>;
} {
const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map();
const formattedErrors = [];
const errorsByFile = new Map<string, number>();
const formattedErrors: string[] = [];
let currentError = [];
let currentError: string[] = [];
for (const line of lines) {
const trimmedLine = line.trim();
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0);
}
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
// Start new error
currentError.push(formatError(line));
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
return { formattedErrors, errorsByFile };
}
function createErrorSummaryTable(errorsByFile) {
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort(
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
return table;
}
function runTypeCheck() {
function runTypeCheck(): { ok: boolean } {
const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available
@@ -150,16 +156,13 @@ function runTypeCheck() {
"false",
...extraArgs,
];
let execArgs = null;
let execArgs: { cmd: string; args: string[] };
try {
const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
} catch {
// fallback to PATH tsc
execArgs = {
cmd: "tsc",
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
};
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
execArgs = { cmd: "tsc", args: runnerArgs };
}
try {
@@ -183,7 +186,21 @@ function runTypeCheck() {
);
return { ok: true };
} catch (error) {
const errorOutput = (error && (error.stderr || error.stdout)) || "";
const execError = error as { stderr?: string; stdout?: string };
const errorOutput = [execError.stdout, execError.stderr]
.filter((chunk): chunk is string => Boolean(chunk))
.join("\n");
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
// launch failure fall through to the "passed" branch and green-light CI.
if (!errorOutput) {
const message = error instanceof Error ? error.message : String(error);
log(
`${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
colors.red,
);
return { ok: false };
}
// Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors

View File

@@ -0,0 +1,221 @@
import { describe, expect, test } from "bun:test";
import type {
MediaStream,
SubtitleDeliveryMethod,
} from "@jellyfin/sdk/lib/generated-client";
import {
compareTracksForMenu,
isExternalSubtitle,
type PlayerSubtitleTrack,
resolveSubtitleTrack,
} from "@/utils/jellyfin/subtitleUtils";
// String-enum values as typed literals — avoids a runtime SDK import (see subtitleUtils.ts).
const External = "External" as SubtitleDeliveryMethod;
const Embed = "Embed" as SubtitleDeliveryMethod;
// --- fixtures --------------------------------------------------------------
const sub = (o: Partial<MediaStream> & { Index: number }): MediaStream =>
({ Type: "Subtitle", ...o }) as MediaStream;
const ext = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
sub({
Index,
DeliveryMethod: External,
IsExternal: true,
DeliveryUrl: `/sub/${Index}.srt`,
...o,
});
const emb = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
sub({ Index, DeliveryMethod: Embed, ...o });
const track = (o: PlayerSubtitleTrack): PlayerSubtitleTrack => o;
// Mirror direct-player.tsx online URL builder.
const urlBuilder =
(base: string) =>
(s: MediaStream): string | undefined =>
s.DeliveryUrl ? `${base}${s.DeliveryUrl}` : undefined;
const resolve = (
streams: MediaStream[],
index: number | undefined,
player: PlayerSubtitleTrack[],
getExpectedExternalUrl = urlBuilder("http://srv"),
) =>
resolveSubtitleTrack({
subtitleStreams: streams,
jellyfinSubtitleIndex: index,
playerTracks: player,
getExpectedExternalUrl,
});
// --- tests -----------------------------------------------------------------
describe("isExternalSubtitle", () => {
test("true for External delivery or the IsExternal flag, not a bare DeliveryUrl", () => {
expect(isExternalSubtitle(ext(0))).toBe(true);
expect(isExternalSubtitle(sub({ Index: 1, IsExternal: true }))).toBe(true);
expect(isExternalSubtitle(emb(2))).toBe(false);
// A DeliveryUrl alone (e.g. an Hls-delivered sub) is NOT a sub-added sidecar.
expect(isExternalSubtitle(sub({ Index: 3, DeliveryUrl: "/x.srt" }))).toBe(
false,
);
});
});
describe("resolveSubtitleTrack — disable / notFound", () => {
test("index -1 or undefined disables", () => {
expect(resolve([], -1, [])).toEqual({ kind: "disable" });
expect(resolve([], undefined, [])).toEqual({ kind: "disable" });
});
test("index not present returns notFound", () => {
expect(resolve([emb(0)], 99, [track({ id: 1 })])).toEqual({
kind: "notFound",
});
});
});
describe("resolveSubtitleTrack — hidden embedded (#954)", () => {
// Server hides embedded subs: MediaStreams lists only the 3 externals,
// but mpv still demuxes the 3 embedded from the file → externals get ids 4,5,6.
const streams = [
ext(0, { Language: "por" }),
ext(1, { Language: "eng" }),
ext(2, { Language: "eng", Title: "SDH" }),
];
const player = [
track({ id: 1, external: false, language: "eng", title: "CC" }),
track({ id: 2, external: false, language: "spa" }),
track({ id: 3, external: false, language: "fre" }),
track({ id: 4, external: true, externalFilename: "http://srv/sub/0.srt" }),
track({ id: 5, external: true, externalFilename: "http://srv/sub/1.srt" }),
track({ id: 6, external: true, externalFilename: "http://srv/sub/2.srt" }),
];
test("each external maps to the right player id by filename (not 1,2,3)", () => {
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 5 });
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 6 });
});
test("falls back to external ordinal when filenames are unavailable", () => {
const noNames = player.map((t) =>
t.external ? { ...t, externalFilename: undefined } : t,
);
expect(resolve(streams, 1, noNames)).toEqual({
kind: "select",
trackId: 5,
});
});
});
describe("resolveSubtitleTrack — external/embed reversal (non-hidden)", () => {
// Jellyfin lists externals first; mpv lists embedded first then externals.
const streams = [
ext(0, { Language: "eng" }),
emb(1, { Language: "spa" }),
emb(2, { Language: "fre" }),
];
const player = [
track({ id: 1, external: false, language: "spa" }),
track({ id: 2, external: false, language: "fre" }),
track({ id: 3, external: true, externalFilename: "http://srv/sub/0.srt" }),
];
test("external resolves by filename, embedded by language", () => {
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 3 });
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 });
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 2 });
});
});
describe("resolveSubtitleTrack — external without DeliveryUrl (#1763 CodeRabbit)", () => {
// Middle external has no DeliveryUrl → never loaded into the player.
const streams = [
ext(0, { Language: "eng", DeliveryUrl: "/sub/a.srt" }),
sub({ Index: 1, DeliveryMethod: External, IsExternal: true }),
ext(2, { Language: "fre", DeliveryUrl: "/sub/c.srt" }),
];
const player = [
track({ id: 4, external: true, externalFilename: "http://srv/sub/a.srt" }),
track({ id: 5, external: true, externalFilename: "http://srv/sub/c.srt" }),
];
test("loaded externals still map correctly despite the gap", () => {
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 5 });
});
test("selecting the unloaded external returns notFound", () => {
expect(resolve(streams, 1, player)).toEqual({ kind: "notFound" });
});
});
describe("resolveSubtitleTrack — embedded matching", () => {
test("unique language match wins even when player order differs (not positional)", () => {
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "jpn" })];
// Player lists them in the OPPOSITE order — a positional map would mis-pick.
const player = [
track({ id: 1, external: false, language: "jpn" }),
track({ id: 2, external: false, language: "eng" }),
];
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 }); // eng
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 }); // jpn
});
test("same-language tracks with no distinguishing title fall back to ordinal among matches", () => {
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "eng" })];
// Both eng, no title → identity can't disambiguate → ordinal among matches.
const player = [
track({ id: 5, external: false, language: "eng" }),
track({ id: 6, external: false, language: "eng" }),
];
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 5 });
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 6 });
});
test("falls back to embedded ordinal when no language/title info", () => {
const streams = [emb(0), emb(1)];
const player = [
track({ id: 1, external: false }),
track({ id: 2, external: false }),
];
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
});
});
describe("compareTracksForMenu — jellyfin-web order", () => {
test("externals sort after embedded despite lower Index", () => {
const sorted = [
ext(0, { Language: "eng" }),
emb(7, { Language: "fra" }),
].sort(compareTracksForMenu);
expect(sorted.map((s) => s.Index)).toEqual([7, 0]);
});
test("forced then default float to the top within a group", () => {
const sorted = [
emb(2, { Language: "eng" }),
emb(1, { Language: "eng", IsDefault: true }),
emb(0, { Language: "eng", IsForced: true }),
].sort(compareTracksForMenu);
expect(sorted.map((s) => s.Index)).toEqual([0, 1, 2]);
});
test("full Okiku order: embedded first, externals last by Index", () => {
const streams = [
ext(0, { Language: "eng" }),
ext(1, { Language: "eng" }),
ext(2, { Language: "fra" }),
ext(3, { Language: "fra" }),
emb(7, { Language: "fra", Title: "French" }),
];
expect([...streams].sort(compareTracksForMenu).map((s) => s.Index)).toEqual(
[7, 0, 1, 2, 3],
);
});
});

View File

@@ -1,91 +1,287 @@
/**
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
* Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in
* the *player's real track list* by identity — never by positional counting.
*
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
* Why: Jellyfin renumbers MediaStreams (externals first); the player enumerates
* embedded-from-container first and externals (`sub-add`) last; and a library that
* hides embedded subs drops them from MediaStreams while the player still demuxes
* them from the file. Positional Index→id mapping therefore mis-selects (e.g.
* picking Spanish shows English). See {@link resolveSubtitleTrack}.
*
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
* and NOT available in MPV's track list.
* and absent from the player's track list.
*/
import {
type MediaSourceInfo,
type MediaStream,
import type {
MediaSourceInfo,
MediaStream,
SubtitleDeliveryMethod,
} from "@jellyfin/sdk/lib/generated-client";
// "External" is the value of SubtitleDeliveryMethod.External. Compared as a typed
// literal so this util needs no *runtime* import of the SDK barrel — which pulls in
// the axios-dependent `/api` modules and breaks unit tests under `bun test`.
const EXTERNAL_DELIVERY = "External" as SubtitleDeliveryMethod;
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
sub.IsTextSubtitleStream === false;
/**
* Determine if a subtitle will be available in MPV's track list.
* A Jellyfin subtitle stream is "external" when the server delivers it as a
* sub-added sidecar — i.e. `DeliveryMethod === External` (or the `IsExternal`
* flag before a device-specific delivery method is assigned).
*
* A subtitle is in MPV if:
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
* Deliberately NOT keyed on `DeliveryUrl`: an Hls-delivered sub also carries a
* `DeliveryUrl` but lives inside the player's track list (not `sub-add`-ed), so
* it must resolve through the embedded path. Keeping this in lockstep with the
* load sites (which only `sub-add` `DeliveryMethod === External`) and with the
* menu comparator below avoids a sub being sorted as embedded yet resolved as
* external (→ `notFound`).
*/
export const isSubtitleInMpv = (
sub: MediaStream,
isTranscoding: boolean,
): boolean => {
// During transcoding, image-based subs are burned in, not in MPV
if (isTranscoding && isImageBasedSubtitle(sub)) {
return false;
}
export const isExternalSubtitle = (sub: MediaStream): boolean =>
sub.DeliveryMethod === EXTERNAL_DELIVERY || sub.IsExternal === true;
// Embed/Hls/External methods mean the sub is loaded into MPV
return (
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
);
/**
* Order subtitle MediaStreams for the selection menu exactly like jellyfin-web's
* `itemHelper.sortTracks`: in-container tracks first then external, and within
* each group forced first, then default, then `Index` ascending. Callers prepend
* their own "None/Off" entry separately.
*
* The Jellyfin server inserts external (sidecar) streams at the FRONT of
* `MediaStreams` (low indices), so raw Index order shows externals first — this
* comparator flips that to match web (externals last). Uses {@link isExternalSubtitle}
* (not the raw `IsExternal` flag) so ordering and resolution agree.
*/
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
Number(isExternalSubtitle(a)) - Number(isExternalSubtitle(b)) ||
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
(a.Index ?? 0) - (b.Index ?? 0);
/**
* Identity of a subtitle track as reported by the *player's real track list*
* (mpv `track-list`, or a Cast media-track list). Player-agnostic on purpose so
* the same resolver can drive the mpv player today and the Chromecast backend later.
*/
export type PlayerSubtitleTrack = {
/** Player-side id used to actually select the track (mpv `sid`, cast trackId). */
id: number;
/** True if loaded from a separate file (mpv `external`). */
external?: boolean;
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
externalFilename?: string;
language?: string;
title?: string;
codec?: string;
};
export type SubtitleSelection =
| { kind: "select"; trackId: number }
| { kind: "disable" }
| { kind: "notFound" };
/** Decode percent-encoding and strip a leading `file://` scheme for tolerant comparison. */
const normalizeUrl = (url: string): string => {
let u = url;
try {
u = decodeURIComponent(u);
} catch {
// not decodable — compare raw
}
return u.replace(/^file:\/\//, "");
};
const externalFilenameMatches = (
trackFilename: string | undefined,
expectedUrl: string | undefined,
): boolean => {
if (!trackFilename || !expectedUrl) return false;
const a = normalizeUrl(trackFilename);
const b = normalizeUrl(expectedUrl);
return a === b || a.endsWith(b) || b.endsWith(a);
};
const eq = (a?: string | null, b?: string | null): boolean =>
!!a && !!b && a.toLowerCase() === b.toLowerCase();
/** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */
const embeddedIdentityMatches = (
track: PlayerSubtitleTrack,
stream: MediaStream,
): boolean => {
if (eq(track.language, stream.Language)) {
// When both carry a title it must agree; otherwise language alone is enough.
if (track.title && stream.Title) return eq(track.title, stream.Title);
return true;
}
// No language on one side — fall back to a title match.
if (!track.language || !stream.Language) return eq(track.title, stream.Title);
return false;
};
/**
* Calculate the MPV track ID for a given Jellyfin subtitle index.
* Resolve the player track id for a given Jellyfin subtitle index by matching
* against the player's REAL track list (identity), never by positional counting.
*
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
* We iterate through all subtitles, counting only those in MPV, until we find
* the one matching the Jellyfin index.
* Why identity, not position: Jellyfin renumbers `MediaStreams` (externals first)
* while the player enumerates embedded-from-container first and externals
* (`sub-add`) last; and when a library hides embedded subs they vanish from
* `MediaStreams` but still physically exist in the file the player demuxes.
* Positional Index→id mapping therefore mis-selects (e.g. picking Spanish shows
* English — issues #954/#1690/#618/#1467/#976/#1451).
*
* @param mediaSource - The media source containing subtitle streams
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
* @param isTranscoding - Whether the stream is being transcoded
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
* Strategy:
* - disabled (-1/undefined) → `disable`
* - external Jellyfin sub → match the player track by `externalFilename`
* (exact identity, immune to hidden-embedded shifts); fall back to the
* ordinal among *loadable* externals (Swiftfin: externals are the list tail).
* - embedded Jellyfin sub → match by language/title among non-external tracks;
* fall back to the embedded ordinal (container order aligns on both sides).
*
* Player-agnostic: pass any player's track list + a URL builder, so the mpv
* player and (later) the Chromecast backend share one source of truth.
*/
export const getMpvSubtitleId = (
mediaSource: MediaSourceInfo | null | undefined,
jellyfinSubtitleIndex: number | undefined,
isTranscoding: boolean,
): number | undefined => {
// -1 or undefined means disabled
export const resolveSubtitleTrack = (params: {
subtitleStreams: MediaStream[] | undefined;
jellyfinSubtitleIndex: number | undefined;
playerTracks: PlayerSubtitleTrack[];
/** Build the exact URL/path an external Jellyfin sub was loaded into the player with. */
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
}): SubtitleSelection => {
const { jellyfinSubtitleIndex, playerTracks, getExpectedExternalUrl } =
params;
const subtitleStreams = params.subtitleStreams ?? [];
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
return -1;
return { kind: "disable" };
}
const allSubs =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex);
if (!target) return { kind: "notFound" };
// Find the subtitle with the matching Jellyfin index
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
if (isExternalSubtitle(target)) {
const playerExternals = playerTracks.filter((t) => t.external === true);
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
return undefined;
}
// 1) Exact identity by external filename — robust against hidden-embedded offset.
const expectedUrl = getExpectedExternalUrl?.(target);
const byName = playerExternals.find((t) =>
externalFilenameMatches(t.externalFilename, expectedUrl),
);
if (byName) return { kind: "select", trackId: byName.id };
// Count MPV track position (1-based)
let mpvIndex = 0;
for (const sub of allSubs) {
if (isSubtitleInMpv(sub, isTranscoding)) {
mpvIndex++;
if (sub.Index === jellyfinSubtitleIndex) {
return mpvIndex;
}
// 2) Fallback: externals are appended in MediaStreams order → ordinal among
// *loadable* externals (those actually added to the player) stays in lockstep
// with the player's external list, skipping ones with no DeliveryUrl (#1763).
const externalStreams = subtitleStreams.filter(isExternalSubtitle);
const loadableExternals = getExpectedExternalUrl
? externalStreams.filter((s) => getExpectedExternalUrl(s))
: externalStreams;
const ordinal = loadableExternals.findIndex(
(s) => s.Index === jellyfinSubtitleIndex,
);
if (ordinal >= 0 && ordinal < playerExternals.length) {
return { kind: "select", trackId: playerExternals[ordinal].id };
}
return { kind: "notFound" };
}
return undefined;
// Embedded / in-container subtitle.
const embeddedStreams = subtitleStreams.filter((s) => !isExternalSubtitle(s));
const playerEmbedded = playerTracks.filter((t) => t.external !== true);
// 1) Identity by language/title (unique match wins).
const identityMatches = playerEmbedded.filter((t) =>
embeddedIdentityMatches(t, target),
);
if (identityMatches.length === 1) {
return { kind: "select", trackId: identityMatches[0].id };
}
// 2) Fallback: embedded order is container order on both sides → ordinal.
const ordinal = embeddedStreams.findIndex(
(s) => s.Index === jellyfinSubtitleIndex,
);
if (identityMatches.length > 1 && ordinal >= 0) {
// Multiple same-language tracks: pick by position among the matches.
const idx = Math.min(ordinal, identityMatches.length - 1);
return { kind: "select", trackId: identityMatches[idx].id };
}
if (ordinal >= 0 && ordinal < playerEmbedded.length) {
return { kind: "select", trackId: playerEmbedded[ordinal].id };
}
return { kind: "notFound" };
};
/**
* A subtitle track as reported by a concrete player's track-list API
* (mpv `getSubtitleTracks`, or a Cast track list). `lang` mirrors mpv's field name.
*/
export type PlayerSubtitleTrackRaw = {
id: number;
lang?: string;
title?: string;
codec?: string;
external?: boolean;
externalFilename?: string;
};
/**
* Minimal player surface needed to select a subtitle. Satisfied structurally by
* the mpv player ref and (later) implementable by the Chromecast backend.
*/
export interface SubtitleSelectablePlayer {
getSubtitleTracks: () => Promise<PlayerSubtitleTrackRaw[] | null | undefined>;
setSubtitleTrack: (trackId: number) => unknown;
disableSubtitles: () => unknown;
}
/**
* Read the player's real track list, resolve the Jellyfin subtitle index by
* identity ({@link resolveSubtitleTrack}) and apply the result. Single entry point
* for both the mobile controls and the player screen, so selection stays
* consistent everywhere. Returns the resolution for callers that want to react.
*/
export const applyMpvSubtitleSelection = async (
player: SubtitleSelectablePlayer | null | undefined,
params: {
subtitleStreams: MediaStream[] | undefined;
jellyfinSubtitleIndex: number;
/** Build the exact URL/path an external sub was loaded into the player with. */
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
},
): Promise<SubtitleSelection> => {
if (!player) return { kind: "notFound" };
// Called fire-and-forget (`void applyMpvSubtitleSelection(...)`), so any native
// rejection from getSubtitleTracks/setSubtitleTrack/disableSubtitles must be
// swallowed here instead of escaping as an unhandled promise rejection.
try {
const tracks = (await player.getSubtitleTracks()) ?? [];
const selection = resolveSubtitleTrack({
subtitleStreams: params.subtitleStreams,
jellyfinSubtitleIndex: params.jellyfinSubtitleIndex,
playerTracks: tracks.map((t) => ({
id: t.id,
external: t.external,
externalFilename: t.externalFilename,
language: t.lang,
title: t.title,
codec: t.codec,
})),
getExpectedExternalUrl: params.getExpectedExternalUrl,
});
if (selection.kind === "select") {
await player.setSubtitleTrack(selection.trackId);
} else if (selection.kind === "disable") {
await player.disableSubtitles();
}
// notFound → leave current selection (e.g. image subs burned in while transcoding)
return selection;
} catch {
return { kind: "notFound" };
}
};
/**

View File

@@ -3,17 +3,17 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { generateDeviceProfile } from "./native";
/**
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
*/
import type {
DeviceProfile,
SubtitleProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
/**
* Download-specific subtitle profiles.
* These are more permissive than streaming profiles since we can embed subtitles.
*/
const downloadSubtitleProfiles = [
const downloadSubtitleProfiles: SubtitleProfile[] = [
// Official formats
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" },
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
/**
* Generates a device profile optimized for downloads.
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
*
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
* @returns {Object} Jellyfin device profile for downloads
*/
export const generateDownloadProfile = (audioMode = "auto") => {
export const generateDownloadProfile = (
audioMode: AudioTranscodeModeType = "auto",
): DeviceProfile => {
// Get the base profile with proper audio codec configuration
const baseProfile = generateDeviceProfile({ audioMode });

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
},
],
SubtitleProfiles: getSubtitleProfiles(),
};
} satisfies DeviceProfile;
return profile;
};

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
// because MPV cannot load them externally over HTTP
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
"pgssub",
"teletext",
"vobsub",
];
] as const;
// Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
"text",
"vplayer",
"xsub",
];
] as const;
export const getSubtitleProfiles = () => {
const profiles = [];
export const getSubtitleProfiles = (): SubtitleProfile[] => {
const profiles: SubtitleProfile[] = [];
// Image-based formats: Embed or Encode (burn-in), NOT External
for (const format of IMAGE_BASED_FORMATS) {
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
};
// Export for use in player filtering
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;

View File

@@ -1,19 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type PlatformType = "ios" | "android";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
export function generateTrackPlayerProfile(
options?: TrackPlayerProfileOptions,
): any;
declare const _default: any;
export default _default;

View File

@@ -3,23 +3,25 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type {
CodecProfile,
DeviceProfile,
DirectPlayProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import type { PlatformType } from "./native";
/**
* @typedef {"ios" | "android"} PlatformType
*
* @typedef {Object} TrackPlayerProfileOptions
* @property {PlatformType} [platform] - Target platform
*/
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
/**
* Audio direct play profiles for react-native-track-player.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
*
* @param {PlatformType} platform
*/
const getDirectPlayProfile = (platform) => {
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
if (platform === "ios") {
// iOS AVPlayer supported formats
return {
@@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => {
/**
* Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/
const getCodecProfile = (platform) => {
const getCodecProfile = (platform: PlatformType): CodecProfile => {
if (platform === "ios") {
// iOS AVPlayer codec constraints
return {
@@ -64,12 +64,11 @@ const getCodecProfile = (platform) => {
* This profile is specifically for standalone audio playback using:
* - AVPlayer on iOS
* - ExoPlayer on Android
*
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile for track player
*/
export const generateTrackPlayerProfile = (options = {}) => {
const platform = options.platform || Platform.OS;
export const generateTrackPlayerProfile = (
options: TrackPlayerProfileOptions = {},
): DeviceProfile => {
const platform = (options.platform || Platform.OS) as PlatformType;
return {
Name: "Track Player",

View File

@@ -5,7 +5,7 @@ import Constants from "expo-constants";
* clientInfo auto-tracks the app version instead of a hardcoded string. */
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
export interface BuildMeta {
commit?: string | null;
branch?: string | null;