Compare commits

..

3 Commits

Author SHA1 Message Date
lance chant
d9677cbece Merge branch 'develop' into fix/subtitles-ordering-fix 2026-06-30 10:16:04 +02:00
lance chant
97ef9b5ee7 Merge branch 'develop' into fix/subtitles-ordering-fix 2026-06-29 07:50:18 +02:00
Lance Chant
d6980cfc8e fix: subtitle ordering
Fixed an issue where external and subrip subtitles were not ordered
correctly

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-23 12:00:11 +02:00
12 changed files with 244 additions and 712 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -535,17 +535,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } 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/lang")?.let { track["lang"] = it }
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
// Identity fields used to map a Jellyfin subtitle to the real track
// (instead of fragile positional counting). `external` + `external-filename`
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
track["external"] = external
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
track["externalFilename"] = it
}
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected track["selected"] = selected

View File

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

View File

@@ -141,14 +141,6 @@ export type SubtitleTrack = {
id: number; id: number;
title?: string; title?: string;
lang?: 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; selected?: boolean;
}; };

View File

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

View File

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