mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-07 04:53:01 +01:00
Compare commits
23 Commits
fix/tv-aud
...
fix/subtit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
376ccd1f93 | ||
|
|
ed8744389b | ||
|
|
5221ed1963 | ||
|
|
461d055c7a | ||
|
|
58f3646477 | ||
|
|
b879026745 | ||
|
|
33ca07ca82 | ||
|
|
e5d56d6ad1 | ||
|
|
80602ecd23 | ||
|
|
14ea18e0d2 | ||
|
|
28fcb303c6 | ||
|
|
90e9084949 | ||
|
|
115c163aeb | ||
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec |
8
.github/workflows/build-apps.yml
vendored
8
.github/workflows/build-apps.yml
vendored
@@ -263,7 +263,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
@@ -335,7 +335,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
@@ -402,7 +402,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
@@ -472,7 +472,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import { LOCAL_SUBTITLE_INDEX_START } from "@/components/video-player/controls/types";
|
||||
import {
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
@@ -49,15 +50,16 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
getExternalSubtitleUrl,
|
||||
getMpvAudioId,
|
||||
getMpvSubtitleId,
|
||||
isImageBasedSubtitle,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
@@ -619,32 +621,20 @@ export default function DirectPlayerPage() {
|
||||
const mediaSource = stream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// Get external subtitle URLs
|
||||
// - Online: prepend API base path to server URLs
|
||||
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
||||
let externalSubs: string[] | undefined;
|
||||
if (!offline && api?.basePath) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
s.DeliveryUrl,
|
||||
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
|
||||
} else if (offline) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
s.DeliveryUrl,
|
||||
).map((s) => s.DeliveryUrl!);
|
||||
}
|
||||
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source
|
||||
// of truth with identity matching (online: basePath + DeliveryUrl unless
|
||||
// IsExternalUrl; offline: local file path stored in DeliveryUrl).
|
||||
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External",
|
||||
)
|
||||
.map((s) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
)
|
||||
.filter((u): u is string => !!u);
|
||||
|
||||
// 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 +652,6 @@ export default function DirectPlayerPage() {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// Pass cache/buffer settings from user preferences
|
||||
cacheConfig: {
|
||||
@@ -710,7 +699,6 @@ export default function DirectPlayerPage() {
|
||||
playbackPositionFromUrl,
|
||||
api?.basePath,
|
||||
api?.accessToken,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
settings.mpvCacheEnabled,
|
||||
@@ -893,27 +881,6 @@ export default function DirectPlayerPage() {
|
||||
// Check if we're transcoding
|
||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||
|
||||
// A transcoded stream only carries the audio track the server encoded
|
||||
// into it — switching requires re-negotiating the stream with the new
|
||||
// index (like the mobile menu's replacePlayer), not an mpv aid change.
|
||||
if (isTranscoding) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item?.Id ?? "",
|
||||
audioIndex: String(index),
|
||||
subtitleIndex: String(currentSubtitleIndex),
|
||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString() ?? "",
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
}).toString();
|
||||
// Destroy the current mpv instance BEFORE navigating, same rationale as
|
||||
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
|
||||
// during the transition, and two simultaneous decoders OOM-kill low-RAM
|
||||
// devices. Resume is preserved via the playbackPosition param.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert Jellyfin index to MPV track ID
|
||||
const mpvTrackId = getMpvAudioId(
|
||||
stream?.mediaSource,
|
||||
@@ -925,41 +892,105 @@ export default function DirectPlayerPage() {
|
||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||
}
|
||||
},
|
||||
[stream?.mediaSource],
|
||||
);
|
||||
|
||||
// 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",
|
||||
);
|
||||
return applyMpvSubtitleSelection(videoRef.current, {
|
||||
subtitleStreams,
|
||||
jellyfinSubtitleIndex,
|
||||
getExpectedExternalUrl: (s) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
});
|
||||
},
|
||||
[stream?.mediaSource, offline, api?.basePath],
|
||||
);
|
||||
|
||||
// Re-negotiate the stream with new track params (server re-processes it,
|
||||
// e.g. to burn an image sub in or out). Same-item mirror of VideoContext's
|
||||
// replacePlayer, resuming at the live position.
|
||||
const replaceWithTrackSelection = useCallback(
|
||||
(params: { subtitleIndex?: string; audioIndex?: string }) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item?.Id ?? "",
|
||||
audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""),
|
||||
subtitleIndex: params.subtitleIndex ?? String(currentSubtitleIndex),
|
||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString() ?? "",
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
}).toString();
|
||||
// Destroy the current mpv instance before re-navigating, same rationale as
|
||||
// goToNextItem: Expo Router briefly holds two players during the
|
||||
// transition and two decoders/surfaces OOM-kill low-RAM devices.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[
|
||||
stream?.mediaSource,
|
||||
item?.Id,
|
||||
currentAudioIndex,
|
||||
currentSubtitleIndex,
|
||||
stream?.mediaSource?.Id,
|
||||
bitrateValue,
|
||||
router,
|
||||
progress,
|
||||
],
|
||||
);
|
||||
|
||||
// TV subtitle track change handler
|
||||
// TV/mobile subtitle track change handler
|
||||
const handleSubtitleIndexChange = useCallback(
|
||||
async (index: number) => {
|
||||
// Local (client-downloaded) subs are loaded via addSubtitleFile, not
|
||||
// resolvable against server streams — just track the live index.
|
||||
if (index <= LOCAL_SUBTITLE_INDEX_START) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
const subs = stream?.mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle",
|
||||
);
|
||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||
const target = subs?.find((s) => s.Index === index);
|
||||
const current = subs?.find((s) => s.Index === currentSubtitleIndex);
|
||||
// Burned-in subs are pixels, not tracks: switching TO one and switching
|
||||
// AWAY from an active one both need a server re-process (same guard as
|
||||
// VideoContext's needsReplace on the mobile menu path).
|
||||
const needsReplace =
|
||||
isTranscoding &&
|
||||
((target && isImageBasedSubtitle(target)) ||
|
||||
(current && isImageBasedSubtitle(current)));
|
||||
if (needsReplace) {
|
||||
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentSubtitleIndex(index);
|
||||
const result = await applySubtitleSelection(index);
|
||||
// Safety net: a menu-listed sub the player can't select (server-burned
|
||||
// Encode, sidecar never sub-added) needs the server to re-process the
|
||||
// stream with it.
|
||||
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||
}
|
||||
},
|
||||
[stream?.mediaSource],
|
||||
[
|
||||
applySubtitleSelection,
|
||||
replaceWithTrackSelection,
|
||||
stream?.mediaSource,
|
||||
currentSubtitleIndex,
|
||||
],
|
||||
);
|
||||
|
||||
// Technical info toggle handler
|
||||
@@ -1078,6 +1109,10 @@ export default function DirectPlayerPage() {
|
||||
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
// Free the current mpv instance before navigating, matching goToNextItem —
|
||||
// otherwise two decoders/surfaces overlap during the transition and can
|
||||
// OOM-kill low-RAM devices.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
}, [
|
||||
previousItem,
|
||||
@@ -1090,9 +1125,24 @@ export default function DirectPlayerPage() {
|
||||
]);
|
||||
|
||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||
const addSubtitleFile = useCallback(async (path: string) => {
|
||||
const addSubtitleFile = useCallback(
|
||||
async (path: string) => {
|
||||
// Set the live index to the new local sub's REAL index BEFORE the add.
|
||||
// Local subs are keyed LOCAL_SUBTITLE_INDEX_START - position, so use the
|
||||
// downloaded path's position (not a blanket sentinel, which would collide
|
||||
// with the first local sub at -100 and mis-record the selection). Any
|
||||
// local index resolves to notFound on the onTracksReady re-apply, so it
|
||||
// still doesn't clobber the freshly selected track; carry-over now keeps
|
||||
// the correct local sub.
|
||||
const locals = itemId ? getSubtitlesForItem(itemId) : [];
|
||||
const pos = locals.findIndex((s) => s.filePath === path);
|
||||
setCurrentSubtitleIndex(
|
||||
LOCAL_SUBTITLE_INDEX_START - (pos >= 0 ? pos : 0),
|
||||
);
|
||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||
}, []);
|
||||
},
|
||||
[itemId],
|
||||
);
|
||||
|
||||
// TV: Refresh subtitle tracks after server-side subtitle download
|
||||
// Re-fetches the media source to pick up newly downloaded subtitles
|
||||
@@ -1324,6 +1374,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 && (
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
@@ -76,20 +75,6 @@ export default function TVOptionModal() {
|
||||
}, [isReady]);
|
||||
|
||||
const handleSelect = (value: any) => {
|
||||
if (modalState?.deferApplyUntilDismissed) {
|
||||
// onSelect navigates (the transcode audio switch replacing the player);
|
||||
// a router.replace fired while this modal is the active route would be
|
||||
// swallowed. Close FIRST, apply after dismissal.
|
||||
const onSelect = modalState.onSelect;
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
InteractionManager.runAfterInteractions(() => onSelect?.(value));
|
||||
return;
|
||||
}
|
||||
// State-only callers (detail page, library filters, settings): run before
|
||||
// closing so the re-render happens while the modal is up. Deferring it until
|
||||
// after dismissal re-renders the page after focus returns and yanks TV
|
||||
// focus, leaving navigation stuck.
|
||||
modalState?.onSelect(value);
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -645,10 +646,23 @@ export default function TVSubtitleModal() {
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(option: { setTrack?: () => void }) => {
|
||||
if (modalState?.deferApplyUntilDismissed) {
|
||||
// Player: setTrack can navigate (replacePlayer for a burn-in switch
|
||||
// while transcoding); a router.replace fired while this modal is the
|
||||
// active route targets the MODAL and is swallowed. Close FIRST, apply
|
||||
// after dismissal.
|
||||
handleClose();
|
||||
InteractionManager.runAfterInteractions(() => option.setTrack?.());
|
||||
return;
|
||||
}
|
||||
// Detail page: setTrack only updates state. Run it BEFORE closing so the
|
||||
// re-render happens while the modal is up; deferring it until after
|
||||
// dismissal re-renders the detail page after focus returns and yanks TV
|
||||
// focus, leaving navigation stuck.
|
||||
option.setTrack?.();
|
||||
handleClose();
|
||||
},
|
||||
[handleClose],
|
||||
[handleClose, modalState?.deferApplyUntilDismissed],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -109,7 +109,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"expo-doctor": "1.20.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.8",
|
||||
"react-test-renderer": "19.2.3",
|
||||
@@ -1015,7 +1015,7 @@
|
||||
|
||||
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
||||
|
||||
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
||||
"expo-doctor": ["expo-doctor@1.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="],
|
||||
|
||||
"expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="],
|
||||
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import {
|
||||
LOCAL_SUBTITLE_INDEX_START,
|
||||
type Track,
|
||||
} from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
@@ -56,6 +59,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 +236,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
|
||||
@@ -248,9 +253,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
// State to trigger refresh of local subtitles list
|
||||
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
||||
|
||||
// Starting index for local (client-downloaded) subtitles
|
||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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(
|
||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
[
|
||||
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || [],
|
||||
) || []),
|
||||
].sort(compareTracksForMenu),
|
||||
[selectedOptions.mediaSource],
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -564,9 +567,6 @@ export const Controls: FC<Props> = ({
|
||||
title: t("item_card.audio"),
|
||||
options: audioOptions,
|
||||
onSelect: handleAudioChange,
|
||||
// In-player audio selection navigates (replacePlayer while transcoding);
|
||||
// apply it after the modal is dismissed so it isn't swallowed.
|
||||
deferApplyUntilDismissed: true,
|
||||
});
|
||||
controlsInteractionRef.current();
|
||||
}, [showOptions, t, audioOptions, handleAudioChange]);
|
||||
@@ -601,6 +601,9 @@ export const Controls: FC<Props> = ({
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleTracks: tracksWithoutDisable,
|
||||
currentSubtitleIndex: subtitleIndex ?? -1,
|
||||
// In-player selection can navigate (replacePlayer for burn-in switches);
|
||||
// apply it after the modal route is dismissed so it isn't swallowed.
|
||||
deferApplyUntilDismissed: true,
|
||||
onDisableSubtitles: () => {
|
||||
// Find and call the "Disable" track's setTrack from VideoContext
|
||||
const disableTrack = videoContextSubtitleTracks?.find(
|
||||
|
||||
@@ -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,16 +58,18 @@ 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 type { Track } from "../types";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
compareTracksForMenu,
|
||||
getExternalSubtitleUrl,
|
||||
isImageBasedSubtitle,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { LOCAL_SUBTITLE_INDEX_START, type Track } from "../types";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
|
||||
// Starting index for local (client-downloaded) subtitles
|
||||
// Uses negative indices to avoid collision with Jellyfin indices
|
||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
interface VideoContextProps {
|
||||
subtitleTracks: Track[] | null;
|
||||
audioTracks: Track[] | null;
|
||||
@@ -87,6 +86,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 +141,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 +179,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 +192,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 +221,84 @@ 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) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: subIdx,
|
||||
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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
return;
|
||||
}
|
||||
|
||||
// MPV track handling
|
||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||
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)) {
|
||||
// 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: -1,
|
||||
setTrack: () => {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
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;
|
||||
},
|
||||
});
|
||||
continue;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate MPV track ID based on type
|
||||
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
||||
let mpvId = -1;
|
||||
commitSubtitleTracks(subs);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
// MPV track handling
|
||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||
if (cancelled) return;
|
||||
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
||||
|
||||
const subs: Track[] = [];
|
||||
|
||||
// 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
|
||||
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) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
}).then((result) => {
|
||||
// Safety net: a menu-listed sub the player can't select (server-
|
||||
// burned Encode, sidecar never sub-added) only shows up after the
|
||||
// server re-processes the stream with it.
|
||||
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -374,12 +366,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 }}>
|
||||
|
||||
@@ -28,4 +28,12 @@ type Track = {
|
||||
localPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Synthetic `Track.index` base for client-side downloaded subtitles (loaded via
|
||||
* `addSubtitleFile`, no matching Jellyfin MediaStream). Local sub k gets
|
||||
* `LOCAL_SUBTITLE_INDEX_START - k`, so `index <= LOCAL_SUBTITLE_INDEX_START`
|
||||
* identifies a local track.
|
||||
*/
|
||||
export const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
|
||||
|
||||
@@ -12,7 +12,6 @@ interface ShowOptionsParams<T> {
|
||||
onSelect: (value: T) => void;
|
||||
cardWidth?: number;
|
||||
cardHeight?: number;
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
}
|
||||
|
||||
export const useTVOptionModal = () => {
|
||||
@@ -27,7 +26,6 @@ export const useTVOptionModal = () => {
|
||||
onSelect: params.onSelect,
|
||||
cardWidth: params.cardWidth,
|
||||
cardHeight: params.cardHeight,
|
||||
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||
});
|
||||
router.push("/(auth)/tv-option-modal");
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ShowSubtitleModalParams {
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
refreshSubtitleTracks?: () => Promise<Track[]>;
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
}
|
||||
|
||||
export const useTVSubtitleModal = () => {
|
||||
@@ -30,6 +31,7 @@ export const useTVSubtitleModal = () => {
|
||||
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
|
||||
refreshSubtitleTracks: params.refreshSubtitleTracks,
|
||||
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||
});
|
||||
router.push("/(auth)/tv-subtitle-modal");
|
||||
},
|
||||
|
||||
@@ -535,6 +535,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
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` is exposed for
|
||||
// diagnostics / potential future exact-index matching; the current
|
||||
// resolver matches embedded tracks by language/title, not ff-index.
|
||||
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
|
||||
@@ -840,6 +853,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||
|
||||
// The disable above can race a JS-side identity selection that
|
||||
// landed before FILE_LOADED (JS no longer passes an initial sid).
|
||||
// Re-emit tracksReady so the idempotent JS re-apply always runs
|
||||
// after it — for embedded-only files this is the only
|
||||
// post-FILE_LOADED fire.
|
||||
mainHandler.post { delegate?.onTracksReady() }
|
||||
|
||||
if (!isReadyToSeek) {
|
||||
isReadyToSeek = true
|
||||
mainHandler.post { delegate?.onReadyToSeek() }
|
||||
|
||||
@@ -508,6 +508,15 @@ final class MPVLayerRenderer {
|
||||
} else {
|
||||
disableSubtitles()
|
||||
}
|
||||
// The disable above can race a JS-side identity selection that
|
||||
// landed before FILE_LOADED (JS no longer passes an initial sid).
|
||||
// Re-emit tracksReady so the idempotent JS re-apply always runs
|
||||
// after it — for embedded-only files this is the only
|
||||
// post-FILE_LOADED fire.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
||||
}
|
||||
if !isReadyToSeek {
|
||||
isReadyToSeek = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -759,7 +768,7 @@ final class MPVLayerRenderer {
|
||||
trackType == "sub" else { continue }
|
||||
|
||||
var trackId: Int64 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
||||
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue }
|
||||
|
||||
var track: [String: Any] = ["id": Int(trackId)]
|
||||
|
||||
@@ -771,11 +780,33 @@ final class MPVLayerRenderer {
|
||||
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` is exposed for
|
||||
// diagnostics / potential future exact-index matching; the current
|
||||
// resolver matches embedded tracks by language/title, not ff-index.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -872,7 +903,7 @@ final class MPVLayerRenderer {
|
||||
trackType == "audio" else { continue }
|
||||
|
||||
var trackId: Int64 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
||||
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue }
|
||||
|
||||
var track: [String: Any] = ["id": Int(trackId)]
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"expo-doctor": "1.20.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.8",
|
||||
"react-test-renderer": "19.2.3",
|
||||
|
||||
@@ -13,15 +13,6 @@ export type TVOptionModalState = {
|
||||
onSelect: (value: any) => void;
|
||||
cardWidth?: number;
|
||||
cardHeight?: number;
|
||||
/**
|
||||
* Run onSelect AFTER the modal route is dismissed. Needed only when onSelect
|
||||
* navigates (the in-player audio switch replacing the player while
|
||||
* transcoding), which the still-active modal route would otherwise swallow.
|
||||
* Default (false) runs onSelect before closing, so state-only callers (detail
|
||||
* page, library filters, settings) don't re-render after focus returns and
|
||||
* lose TV focus.
|
||||
*/
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
} | null;
|
||||
|
||||
export const tvOptionModalAtom = atom<TVOptionModalState>(null);
|
||||
|
||||
@@ -11,6 +11,14 @@ export type TVSubtitleModalState = {
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
refreshSubtitleTracks?: () => Promise<Track[]>;
|
||||
/**
|
||||
* Run the selection callback AFTER the modal route is dismissed. Needed when
|
||||
* `setTrack` navigates (the player's replacePlayer for a burn-in switch),
|
||||
* which would be swallowed by the still-active modal route. Leave false for
|
||||
* callers whose selection only updates state (the item detail page), so the
|
||||
* update runs before dismissal and doesn't yank focus after it returns.
|
||||
*/
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
} | null;
|
||||
|
||||
export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null);
|
||||
|
||||
485
utils/jellyfin/subtitleUtils.test.ts
Normal file
485
utils/jellyfin/subtitleUtils.test.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
MediaStream,
|
||||
SubtitleDeliveryMethod,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
compareTracksForMenu,
|
||||
getExternalSubtitleUrl,
|
||||
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;
|
||||
const Encode = "Encode" as SubtitleDeliveryMethod;
|
||||
const Hls = "Hls" 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,
|
||||
);
|
||||
});
|
||||
|
||||
test("a sidecar re-delivered by the server (Hls/Encode) is NOT external", () => {
|
||||
// IsExternal only wins while no device-specific delivery method is assigned;
|
||||
// once the server picks Hls (inside the stream) or Encode (burned), the
|
||||
// track is not a sub-added sidecar and must not use the external path.
|
||||
expect(
|
||||
isExternalSubtitle(
|
||||
sub({ Index: 0, IsExternal: true, DeliveryMethod: Hls }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isExternalSubtitle(
|
||||
sub({ Index: 0, IsExternal: true, DeliveryMethod: Encode }),
|
||||
),
|
||||
).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],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — same-identity group ordinal (duplicate languages)", () => {
|
||||
// [jpn, eng, eng] with no titles: the eng group starts at embedded position 1,
|
||||
// so the group ordinal (not the global one) must drive the pick.
|
||||
const streams = [
|
||||
emb(0, { Language: "jpn" }),
|
||||
emb(1, { Language: "eng" }),
|
||||
emb(2, { Language: "eng" }),
|
||||
];
|
||||
const playerTracks = [
|
||||
track({ id: 1, language: "jpn" }),
|
||||
track({ id: 2, language: "eng" }),
|
||||
track({ id: 3, language: "eng" }),
|
||||
];
|
||||
|
||||
test("first eng selects the first matching player track", () => {
|
||||
expect(resolve(streams, 1, playerTracks)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("second eng selects the second matching player track", () => {
|
||||
expect(resolve(streams, 2, playerTracks)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — server-burned (Encode) streams", () => {
|
||||
const burned = emb(2, {
|
||||
DeliveryMethod: Encode,
|
||||
IsTextSubtitleStream: false,
|
||||
Codec: "pgssub",
|
||||
});
|
||||
|
||||
test("selecting a burned-in sub returns burnedIn (a stream refresh, not a track)", () => {
|
||||
expect(resolve([burned, emb(3)], 2, [track({ id: 1 })])).toEqual({
|
||||
kind: "burnedIn",
|
||||
});
|
||||
});
|
||||
|
||||
test("burned-in streams do not shift the embedded ordinal", () => {
|
||||
// Player only demuxes the two untagged text subs; the burned PGS is pixels.
|
||||
const streams = [burned, emb(3), emb(4)];
|
||||
const playerTracks = [track({ id: 1 }), track({ id: 2 })];
|
||||
expect(resolve(streams, 3, playerTracks)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 1,
|
||||
});
|
||||
expect(resolve([burned, emb(3)], 3, [track({ id: 1 })])).toEqual({
|
||||
kind: "select",
|
||||
trackId: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — Hls-delivered sidecar goes through the embedded path", () => {
|
||||
test("resolves by identity against the player's in-stream track", () => {
|
||||
const hlsSidecar = sub({
|
||||
Index: 0,
|
||||
IsExternal: true,
|
||||
DeliveryMethod: Hls,
|
||||
DeliveryUrl: "/videos/x/subs/0.vtt",
|
||||
Language: "fre",
|
||||
});
|
||||
const r = resolve([hlsSidecar, emb(1, { Language: "eng" })], 0, [
|
||||
track({ id: 1, language: "fre" }),
|
||||
track({ id: 2, language: "eng" }),
|
||||
]);
|
||||
expect(r).toEqual({ kind: "select", trackId: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMpvSubtitleSelection — short-circuits", () => {
|
||||
test("disable (-1) never reads the player track list", async () => {
|
||||
let enumerated = false;
|
||||
let disabled = false;
|
||||
const r = await applyMpvSubtitleSelection(
|
||||
{
|
||||
getSubtitleTracks: async () => {
|
||||
enumerated = true;
|
||||
return [];
|
||||
},
|
||||
setSubtitleTrack: () => {},
|
||||
disableSubtitles: () => {
|
||||
disabled = true;
|
||||
},
|
||||
},
|
||||
{ subtitleStreams: [], jellyfinSubtitleIndex: -1 },
|
||||
);
|
||||
expect(r).toEqual({ kind: "disable" });
|
||||
expect(disabled).toBe(true);
|
||||
expect(enumerated).toBe(false);
|
||||
});
|
||||
|
||||
test("burned-in target returns without touching the player", async () => {
|
||||
let enumerated = false;
|
||||
const r = await applyMpvSubtitleSelection(
|
||||
{
|
||||
getSubtitleTracks: async () => {
|
||||
enumerated = true;
|
||||
return [];
|
||||
},
|
||||
setSubtitleTrack: () => {},
|
||||
disableSubtitles: () => {},
|
||||
},
|
||||
{
|
||||
subtitleStreams: [
|
||||
emb(2, { DeliveryMethod: Encode, IsTextSubtitleStream: false }),
|
||||
],
|
||||
jellyfinSubtitleIndex: 2,
|
||||
},
|
||||
);
|
||||
expect(r).toEqual({ kind: "burnedIn" });
|
||||
expect(enumerated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExternalSubtitleUrl — server contract (MediaInfoHelper)", () => {
|
||||
test("server-relative DeliveryUrl gets the basePath prefix", () => {
|
||||
expect(
|
||||
getExternalSubtitleUrl(ext(0, { DeliveryUrl: "/sub/0.srt" }), {
|
||||
offline: false,
|
||||
basePath: "http://srv",
|
||||
}),
|
||||
).toBe("http://srv/sub/0.srt");
|
||||
});
|
||||
|
||||
test("IsExternalUrl means DeliveryUrl is already absolute — no prefix", () => {
|
||||
expect(
|
||||
getExternalSubtitleUrl(
|
||||
ext(0, {
|
||||
DeliveryUrl: "https://cdn.example/sub.srt",
|
||||
IsExternalUrl: true,
|
||||
}),
|
||||
{ offline: false, basePath: "http://srv" },
|
||||
),
|
||||
).toBe("https://cdn.example/sub.srt");
|
||||
});
|
||||
|
||||
test("offline returns the stored local path as-is", () => {
|
||||
expect(
|
||||
getExternalSubtitleUrl(ext(0, { DeliveryUrl: "file:///subs/0.srt" }), {
|
||||
offline: true,
|
||||
basePath: "http://srv",
|
||||
}),
|
||||
).toBe("file:///subs/0.srt");
|
||||
});
|
||||
|
||||
test("no DeliveryUrl or no basePath online → undefined", () => {
|
||||
expect(
|
||||
getExternalSubtitleUrl(sub({ Index: 0, IsExternal: true }), {
|
||||
offline: false,
|
||||
basePath: "http://srv",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getExternalSubtitleUrl(ext(0), { offline: false, basePath: undefined }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — language tag variants (ISO 639-1 / 639-2 B-T / IETF)", () => {
|
||||
test("Jellyfin 639-2/B matches mpv 639-2/T and 639-1 tags", () => {
|
||||
// Server says "ger" (639-2/B); muxers can surface "deu" (/T) or "de" (639-1).
|
||||
const streams = [emb(0, { Language: "ger" }), emb(1, { Language: "fre" })];
|
||||
const playerT = [
|
||||
track({ id: 1, language: "deu" }),
|
||||
track({ id: 2, language: "fra" }),
|
||||
];
|
||||
expect(resolve(streams, 0, playerT)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 1,
|
||||
});
|
||||
expect(resolve(streams, 1, playerT)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 2,
|
||||
});
|
||||
|
||||
const player1 = [
|
||||
track({ id: 1, language: "de" }),
|
||||
track({ id: 2, language: "fr" }),
|
||||
];
|
||||
expect(resolve(streams, 0, player1)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("IETF tags reduce to their primary subtag", () => {
|
||||
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "spa" })];
|
||||
const player = [
|
||||
track({ id: 1, language: "en-US" }),
|
||||
track({ id: 2, language: "es-419" }),
|
||||
];
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 1 });
|
||||
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
|
||||
});
|
||||
|
||||
test("different languages still never match ('ger' vs 'gre')", () => {
|
||||
const streams = [emb(0, { Language: "ger" })];
|
||||
const player = [
|
||||
track({ id: 1, language: "gre" }),
|
||||
track({ id: 2, language: "ger" }),
|
||||
];
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareTracksForMenu — stable order across play methods (8 Mile live find)", () => {
|
||||
test("transcode re-delivery (SRT→External, PGS→Encode) must not reshuffle the menu", () => {
|
||||
// In-file order: srt(1) srt(2) pgs(3) pgs(4), plus a real sidecar ext(0).
|
||||
const directPlay = [
|
||||
ext(0, { Language: "eng" }),
|
||||
emb(1, { Language: "fre" }),
|
||||
emb(2, { Language: "eng" }),
|
||||
emb(3, { Language: "fre", IsTextSubtitleStream: false }),
|
||||
emb(4, { Language: "eng", IsTextSubtitleStream: false }),
|
||||
];
|
||||
// Same file while transcoding: server re-delivers embedded text as External
|
||||
// (extracted) and burns image subs (Encode). IsExternal stays a FILE property.
|
||||
const transcoding = [
|
||||
ext(0, { Language: "eng" }),
|
||||
emb(1, { Language: "fre", DeliveryMethod: External, DeliveryUrl: "/x1" }),
|
||||
emb(2, { Language: "eng", DeliveryMethod: External, DeliveryUrl: "/x2" }),
|
||||
emb(3, {
|
||||
Language: "fre",
|
||||
IsTextSubtitleStream: false,
|
||||
DeliveryMethod: Encode,
|
||||
}),
|
||||
emb(4, {
|
||||
Language: "eng",
|
||||
IsTextSubtitleStream: false,
|
||||
DeliveryMethod: Encode,
|
||||
}),
|
||||
];
|
||||
const order = (streams: MediaStream[]) =>
|
||||
[...streams].sort(compareTracksForMenu).map((s) => s.Index);
|
||||
expect(order(directPlay)).toEqual([1, 2, 3, 4, 0]);
|
||||
expect(order(transcoding)).toEqual(order(directPlay));
|
||||
});
|
||||
});
|
||||
@@ -1,91 +1,584 @@
|
||||
/**
|
||||
* 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;
|
||||
const ENCODE_DELIVERY = "Encode" 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 subtitle is in MPV if:
|
||||
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
||||
* Burned into the video by the server (`DeliveryMethod === Encode`, e.g. image
|
||||
* subs while transcoding, or sidecar formats no profile can deliver). Never a
|
||||
* selectable player track — switching to/away requires a stream refresh.
|
||||
*/
|
||||
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 isBurnedInSubtitle = (sub: MediaStream): boolean =>
|
||||
sub.DeliveryMethod === ENCODE_DELIVERY;
|
||||
|
||||
// Embed/Hls/External methods mean the sub is loaded into MPV
|
||||
return (
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
||||
);
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* 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 isExternalSubtitle = (sub: MediaStream): boolean =>
|
||||
sub.DeliveryMethod === EXTERNAL_DELIVERY ||
|
||||
(sub.DeliveryMethod == null && sub.IsExternal === true);
|
||||
|
||||
/**
|
||||
* The exact URL/path an external sub is (or would be) loaded into the player
|
||||
* with. Single source of truth for BOTH the load site (`videoSource`
|
||||
* externalSubtitles) and identity matching (`getExpectedExternalUrl`) — the
|
||||
* filename match only works while the two stay byte-identical.
|
||||
*
|
||||
* Server contract (MediaInfoHelper.SetDeviceSpecificSubtitleInfo): DeliveryUrl
|
||||
* is server-relative (`/Videos/...`, may carry `?ApiKey=`) UNLESS
|
||||
* `IsExternalUrl` is set — then it is already an absolute URL and must not be
|
||||
* prefixed. Offline: DeliveryUrl holds the local file path.
|
||||
*/
|
||||
export const getExternalSubtitleUrl = (
|
||||
sub: MediaStream,
|
||||
opts: { offline: boolean; basePath?: string | null },
|
||||
): string | undefined => {
|
||||
if (!sub.DeliveryUrl) return undefined;
|
||||
if (opts.offline || sub.IsExternalUrl) return sub.DeliveryUrl;
|
||||
return opts.basePath ? `${opts.basePath}${sub.DeliveryUrl}` : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @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
|
||||
* 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 the raw `IsExternal`
|
||||
* flag exactly like web's `sortTracks` (itemHelper.js): it is a property of the
|
||||
* FILE, so the menu order stays identical between direct play and transcode —
|
||||
* delivery-based grouping would reshuffle entries when the server re-delivers
|
||||
* extracted text subs as External and burns image subs (Encode). Ordering is
|
||||
* purely cosmetic; selection resolves by `Index` identity regardless.
|
||||
*/
|
||||
export const getMpvSubtitleId = (
|
||||
mediaSource: MediaSourceInfo | null | undefined,
|
||||
jellyfinSubtitleIndex: number | undefined,
|
||||
isTranscoding: boolean,
|
||||
): number | undefined => {
|
||||
// -1 or undefined means disabled
|
||||
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
|
||||
Number(a.IsExternal ?? false) - Number(b.IsExternal ?? false) ||
|
||||
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
|
||||
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
|
||||
// Missing Index sorts to the end (not 0, which would float it to the top and
|
||||
// collide with a real Index 0).
|
||||
(a.Index ?? Number.MAX_SAFE_INTEGER) - (b.Index ?? Number.MAX_SAFE_INTEGER);
|
||||
|
||||
/**
|
||||
* 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" }
|
||||
/** Target is server-burned (Encode) — only a stream refresh can show/hide it. */
|
||||
| { kind: "burnedIn" }
|
||||
| { 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();
|
||||
|
||||
/**
|
||||
* ISO 639-1 (2-letter) and ISO 639-2/T tags mapped to their ISO 639-2/B form,
|
||||
* so tags from different muxers/servers compare equal ("de"/"deu"/"ger" → "ger").
|
||||
* Jellyfin normalizes probe results to 639-2/B, but mkv `LanguageIETF` tags
|
||||
* ("en-US") and some muxers' 639-1 or /T tags leak through to mpv's `lang`.
|
||||
*/
|
||||
const LANG_CANONICAL: Record<string, string> = {
|
||||
// ISO 639-1 → 639-2/B
|
||||
aa: "aar",
|
||||
ab: "abk",
|
||||
ae: "ave",
|
||||
af: "afr",
|
||||
ak: "aka",
|
||||
am: "amh",
|
||||
an: "arg",
|
||||
ar: "ara",
|
||||
as: "asm",
|
||||
av: "ava",
|
||||
ay: "aym",
|
||||
az: "aze",
|
||||
ba: "bak",
|
||||
be: "bel",
|
||||
bg: "bul",
|
||||
bh: "bih",
|
||||
bi: "bis",
|
||||
bm: "bam",
|
||||
bn: "ben",
|
||||
bo: "tib",
|
||||
br: "bre",
|
||||
bs: "bos",
|
||||
ca: "cat",
|
||||
ce: "che",
|
||||
ch: "cha",
|
||||
co: "cos",
|
||||
cr: "cre",
|
||||
cs: "cze",
|
||||
cu: "chu",
|
||||
cv: "chv",
|
||||
cy: "wel",
|
||||
da: "dan",
|
||||
de: "ger",
|
||||
dv: "div",
|
||||
dz: "dzo",
|
||||
ee: "ewe",
|
||||
el: "gre",
|
||||
en: "eng",
|
||||
eo: "epo",
|
||||
es: "spa",
|
||||
et: "est",
|
||||
eu: "baq",
|
||||
fa: "per",
|
||||
ff: "ful",
|
||||
fi: "fin",
|
||||
fj: "fij",
|
||||
fo: "fao",
|
||||
fr: "fre",
|
||||
fy: "fry",
|
||||
ga: "gle",
|
||||
gd: "gla",
|
||||
gl: "glg",
|
||||
gn: "grn",
|
||||
gu: "guj",
|
||||
gv: "glv",
|
||||
ha: "hau",
|
||||
he: "heb",
|
||||
hi: "hin",
|
||||
ho: "hmo",
|
||||
hr: "hrv",
|
||||
ht: "hat",
|
||||
hu: "hun",
|
||||
hy: "arm",
|
||||
hz: "her",
|
||||
ia: "ina",
|
||||
id: "ind",
|
||||
ie: "ile",
|
||||
ig: "ibo",
|
||||
ii: "iii",
|
||||
ik: "ipk",
|
||||
io: "ido",
|
||||
is: "ice",
|
||||
it: "ita",
|
||||
iu: "iku",
|
||||
ja: "jpn",
|
||||
jv: "jav",
|
||||
ka: "geo",
|
||||
kg: "kon",
|
||||
ki: "kik",
|
||||
kj: "kua",
|
||||
kk: "kaz",
|
||||
kl: "kal",
|
||||
km: "khm",
|
||||
kn: "kan",
|
||||
ko: "kor",
|
||||
kr: "kau",
|
||||
ks: "kas",
|
||||
ku: "kur",
|
||||
kv: "kom",
|
||||
kw: "cor",
|
||||
ky: "kir",
|
||||
la: "lat",
|
||||
lb: "ltz",
|
||||
lg: "lug",
|
||||
li: "lim",
|
||||
ln: "lin",
|
||||
lo: "lao",
|
||||
lt: "lit",
|
||||
lu: "lub",
|
||||
lv: "lav",
|
||||
mg: "mlg",
|
||||
mh: "mah",
|
||||
mi: "mao",
|
||||
mk: "mac",
|
||||
ml: "mal",
|
||||
mn: "mon",
|
||||
mr: "mar",
|
||||
ms: "may",
|
||||
mt: "mlt",
|
||||
my: "bur",
|
||||
na: "nau",
|
||||
nb: "nob",
|
||||
nd: "nde",
|
||||
ne: "nep",
|
||||
ng: "ndo",
|
||||
nl: "dut",
|
||||
nn: "nno",
|
||||
no: "nor",
|
||||
nr: "nbl",
|
||||
nv: "nav",
|
||||
ny: "nya",
|
||||
oc: "oci",
|
||||
oj: "oji",
|
||||
om: "orm",
|
||||
or: "ori",
|
||||
os: "oss",
|
||||
pa: "pan",
|
||||
pi: "pli",
|
||||
pl: "pol",
|
||||
ps: "pus",
|
||||
pt: "por",
|
||||
qu: "que",
|
||||
rm: "roh",
|
||||
rn: "run",
|
||||
ro: "rum",
|
||||
ru: "rus",
|
||||
rw: "kin",
|
||||
sa: "san",
|
||||
sc: "srd",
|
||||
sd: "snd",
|
||||
se: "sme",
|
||||
sg: "sag",
|
||||
si: "sin",
|
||||
sk: "slo",
|
||||
sl: "slv",
|
||||
sm: "smo",
|
||||
sn: "sna",
|
||||
so: "som",
|
||||
sq: "alb",
|
||||
sr: "srp",
|
||||
ss: "ssw",
|
||||
st: "sot",
|
||||
su: "sun",
|
||||
sv: "swe",
|
||||
sw: "swa",
|
||||
ta: "tam",
|
||||
te: "tel",
|
||||
tg: "tgk",
|
||||
th: "tha",
|
||||
ti: "tir",
|
||||
tk: "tuk",
|
||||
tl: "tgl",
|
||||
tn: "tsn",
|
||||
to: "ton",
|
||||
tr: "tur",
|
||||
ts: "tso",
|
||||
tt: "tat",
|
||||
tw: "twi",
|
||||
ty: "tah",
|
||||
ug: "uig",
|
||||
uk: "ukr",
|
||||
ur: "urd",
|
||||
uz: "uzb",
|
||||
ve: "ven",
|
||||
vi: "vie",
|
||||
vo: "vol",
|
||||
wa: "wln",
|
||||
wo: "wol",
|
||||
xh: "xho",
|
||||
yi: "yid",
|
||||
yo: "yor",
|
||||
za: "zha",
|
||||
zh: "chi",
|
||||
zu: "zul",
|
||||
// ISO 639-2/T → /B (the 20 languages with two distinct 3-letter codes)
|
||||
bod: "tib",
|
||||
ces: "cze",
|
||||
cym: "wel",
|
||||
deu: "ger",
|
||||
ell: "gre",
|
||||
eus: "baq",
|
||||
fas: "per",
|
||||
fra: "fre",
|
||||
hye: "arm",
|
||||
isl: "ice",
|
||||
kat: "geo",
|
||||
mkd: "mac",
|
||||
mri: "mao",
|
||||
msa: "may",
|
||||
mya: "bur",
|
||||
nld: "dut",
|
||||
ron: "rum",
|
||||
slk: "slo",
|
||||
sqi: "alb",
|
||||
zho: "chi",
|
||||
};
|
||||
|
||||
/** Canonicalize a language tag: lowercase, primary subtag ("en-US" → "en"), 639-2/B. */
|
||||
const canonicalLang = (raw: string): string => {
|
||||
const primary = raw.trim().toLowerCase().split("-")[0];
|
||||
return LANG_CANONICAL[primary] ?? primary;
|
||||
};
|
||||
|
||||
/** Language-tag comparison across ISO 639-1 / 639-2 B/T / IETF variants. */
|
||||
const langEq = (a?: string | null, b?: string | null): boolean =>
|
||||
!!a && !!b && canonicalLang(a) === canonicalLang(b);
|
||||
|
||||
/** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */
|
||||
const embeddedIdentityMatches = (
|
||||
track: PlayerSubtitleTrack,
|
||||
stream: MediaStream,
|
||||
): boolean => {
|
||||
if (langEq(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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* 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 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);
|
||||
// Server-burned subs are pixels, not tracks — signal the caller to refresh
|
||||
// the stream instead of hunting for a track that cannot exist.
|
||||
if (isBurnedInSubtitle(target)) return { kind: "burnedIn" };
|
||||
|
||||
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
||||
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
||||
return undefined;
|
||||
if (isExternalSubtitle(target)) {
|
||||
const playerExternals = playerTracks.filter((t) => t.external === true);
|
||||
|
||||
// 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 };
|
||||
|
||||
// 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" };
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Embedded / in-container subtitle. Burned-in (Encode) streams are excluded:
|
||||
// they are baked into the video and never appear in the player's track list,
|
||||
// so counting them would shift every ordinal below.
|
||||
const embeddedStreams = subtitleStreams.filter(
|
||||
(s) => !isExternalSubtitle(s) && !isBurnedInSubtitle(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) Multiple same-identity tracks: ordinal within the same-identity GROUP —
|
||||
// the k-th matching stream corresponds to the k-th matching player track
|
||||
// (container order is preserved on both sides, filter preserves order).
|
||||
// The group ordinal, not the global one: with [jpn, eng, eng] the first
|
||||
// eng is global position 1 but group position 0.
|
||||
if (identityMatches.length > 1) {
|
||||
const groupStreams = embeddedStreams.filter((s) =>
|
||||
identityMatches.some((t) => embeddedIdentityMatches(t, s)),
|
||||
);
|
||||
const groupOrdinal = groupStreams.findIndex(
|
||||
(s) => s.Index === jellyfinSubtitleIndex,
|
||||
);
|
||||
if (groupOrdinal >= 0) {
|
||||
const idx = Math.min(groupOrdinal, identityMatches.length - 1);
|
||||
return { kind: "select", trackId: identityMatches[idx].id };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
// 3) Fallback: embedded order is container order on both sides → ordinal.
|
||||
const ordinal = embeddedStreams.findIndex(
|
||||
(s) => s.Index === jellyfinSubtitleIndex,
|
||||
);
|
||||
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 {
|
||||
// Short-circuit the outcomes that don't need the player's track list, so
|
||||
// the common subtitles-off case skips a full native enumeration.
|
||||
if (params.jellyfinSubtitleIndex === -1) {
|
||||
await player.disableSubtitles();
|
||||
return { kind: "disable" };
|
||||
}
|
||||
const burnTarget = params.subtitleStreams?.find(
|
||||
(s) => s.Index === params.jellyfinSubtitleIndex,
|
||||
);
|
||||
if (burnTarget && isBurnedInSubtitle(burnTarget)) {
|
||||
return { kind: "burnedIn" };
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user