mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-30 09:32:50 +01:00
Compare commits
3 Commits
fix/subtit
...
fix/subtit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9677cbece | ||
|
|
97ef9b5ee7 | ||
|
|
d6980cfc8e |
@@ -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 && (
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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" };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
return undefined;
|
||||||
* A subtitle track as reported by a concrete player's track-list API
|
|
||||||
* (mpv `getSubtitleTracks`, or a Cast track list). `lang` mirrors mpv's field name.
|
|
||||||
*/
|
|
||||||
export type PlayerSubtitleTrackRaw = {
|
|
||||||
id: number;
|
|
||||||
lang?: string;
|
|
||||||
title?: string;
|
|
||||||
codec?: string;
|
|
||||||
external?: boolean;
|
|
||||||
externalFilename?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal player surface needed to select a subtitle. Satisfied structurally by
|
|
||||||
* the mpv player ref and (later) implementable by the Chromecast backend.
|
|
||||||
*/
|
|
||||||
export interface SubtitleSelectablePlayer {
|
|
||||||
getSubtitleTracks: () => Promise<PlayerSubtitleTrackRaw[] | null | undefined>;
|
|
||||||
setSubtitleTrack: (trackId: number) => unknown;
|
|
||||||
disableSubtitles: () => unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the player's real track list, resolve the Jellyfin subtitle index by
|
|
||||||
* identity ({@link resolveSubtitleTrack}) and apply the result. Single entry point
|
|
||||||
* for both the mobile controls and the player screen, so selection stays
|
|
||||||
* consistent everywhere. Returns the resolution for callers that want to react.
|
|
||||||
*/
|
|
||||||
export const applyMpvSubtitleSelection = async (
|
|
||||||
player: SubtitleSelectablePlayer | null | undefined,
|
|
||||||
params: {
|
|
||||||
subtitleStreams: MediaStream[] | undefined;
|
|
||||||
jellyfinSubtitleIndex: number;
|
|
||||||
/** Build the exact URL/path an external sub was loaded into the player with. */
|
|
||||||
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
|
||||||
},
|
|
||||||
): Promise<SubtitleSelection> => {
|
|
||||||
if (!player) return { kind: "notFound" };
|
|
||||||
|
|
||||||
// Called fire-and-forget (`void applyMpvSubtitleSelection(...)`), so any native
|
|
||||||
// rejection from getSubtitleTracks/setSubtitleTrack/disableSubtitles must be
|
|
||||||
// swallowed here instead of escaping as an unhandled promise rejection.
|
|
||||||
try {
|
|
||||||
const tracks = (await player.getSubtitleTracks()) ?? [];
|
|
||||||
const selection = resolveSubtitleTrack({
|
|
||||||
subtitleStreams: params.subtitleStreams,
|
|
||||||
jellyfinSubtitleIndex: params.jellyfinSubtitleIndex,
|
|
||||||
playerTracks: tracks.map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
external: t.external,
|
|
||||||
externalFilename: t.externalFilename,
|
|
||||||
language: t.lang,
|
|
||||||
title: t.title,
|
|
||||||
codec: t.codec,
|
|
||||||
})),
|
|
||||||
getExpectedExternalUrl: params.getExpectedExternalUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selection.kind === "select") {
|
|
||||||
await player.setSubtitleTrack(selection.trackId);
|
|
||||||
} else if (selection.kind === "disable") {
|
|
||||||
await player.disableSubtitles();
|
|
||||||
}
|
|
||||||
// notFound → leave current selection (e.g. image subs burned in while transcoding)
|
|
||||||
return selection;
|
|
||||||
} catch {
|
|
||||||
return { kind: "notFound" };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user