fix(tv): resolve subtitle selector index mismatch using VideoContext tracks

This commit is contained in:
Fredrik Burmester
2026-01-22 08:29:57 +01:00
parent be92b5d75e
commit be2fd53f31
5 changed files with 97 additions and 52 deletions

View File

@@ -21,6 +21,7 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVTabButton, useTVFocusAnimation } from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types";
import useRouter from "@/hooks/useAppRouter";
import {
type SubtitleSearchResult,
@@ -544,7 +545,7 @@ export default function TVSubtitleModal() {
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0;
const trackIdx = subtitleTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
(t) => t.index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [subtitleTracks, currentSubtitleIndex]);
@@ -612,11 +613,11 @@ export default function TVSubtitleModal() {
);
const handleTrackSelect = useCallback(
(index: number) => {
modalState?.onSubtitleIndexChange(index);
(option: { setTrack?: () => void }) => {
option.setTrack?.();
handleClose();
},
[modalState, handleClose],
[handleClose],
);
const handleDownload = useCallback(
@@ -683,16 +684,17 @@ export default function TVSubtitleModal() {
sublabel: undefined as string | undefined,
value: -1,
selected: currentSubtitleIndex === -1,
setTrack: () => modalState?.onDisableSubtitles?.(),
};
const options = subtitleTracks.map((track) => ({
label:
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
sublabel: track.Codec?.toUpperCase(),
value: track.Index!,
selected: track.Index === currentSubtitleIndex,
const options = subtitleTracks.map((track: Track) => ({
label: track.name,
sublabel: undefined as string | undefined,
value: track.index,
selected: track.index === currentSubtitleIndex,
setTrack: track.setTrack,
}));
return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t]);
}, [subtitleTracks, currentSubtitleIndex, t, modalState]);
if (!modalState) {
return null;
@@ -762,7 +764,7 @@ export default function TVSubtitleModal() {
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedTrackIndex}
onPress={() => handleTrackSelect(option.value)}
onPress={() => handleTrackSelect(option)}
/>
))}
</ScrollView>

View File

@@ -9,7 +9,13 @@ import { useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -30,6 +36,7 @@ import {
TVSeriesNavigation,
TVTechnicalDetails,
} from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
@@ -145,14 +152,32 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Get available subtitle tracks
const subtitleTracks = useMemo(() => {
// Get available subtitle tracks (raw MediaStream[] for label lookup)
const subtitleStreams = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Store handleSubtitleChange in a ref for stable callback reference
const handleSubtitleChangeRef = useRef<((index: number) => void) | null>(
null,
);
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
const subtitleTracksForModal = useMemo((): Track[] => {
return subtitleStreams.map((stream) => ({
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
index: stream.Index ?? -1,
setTrack: () => {
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
},
}));
}, [subtitleStreams]);
// Get available media sources
const mediaSources = useMemo(() => {
return (itemWithSources ?? item)?.MediaSources ?? [];
@@ -207,6 +232,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
);
}, []);
// Keep the ref updated with the latest callback
handleSubtitleChangeRef.current = handleSubtitleChange;
const handleMediaSourceChange = useCallback(
(mediaSource: MediaSourceInfo) => {
const defaultAudio = mediaSource.MediaStreams?.find(
@@ -241,9 +269,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}, [item?.Id, queryClient]);
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
const refreshSubtitleTracks = useCallback(async (): Promise<
MediaStream[]
> => {
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
if (!api || !item?.Id) return [];
try {
@@ -262,12 +288,22 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)
: freshItem.MediaSources?.[0];
// Return subtitle tracks from the fresh data
return (
// Get subtitle streams from the fresh data
const streams =
mediaSource?.MediaStreams?.filter(
(s: MediaStream) => s.Type === "Subtitle",
) ?? []
);
) ?? [];
// Convert to Track[] with setTrack callbacks
return streams.map((stream) => ({
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
index: stream.Index ?? -1,
setTrack: () => {
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
},
}));
} catch (error) {
console.error("Failed to refresh subtitle tracks:", error);
return [];
@@ -285,13 +321,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const selectedSubtitleLabel = useMemo(() => {
if (selectedOptions?.subtitleIndex === -1)
return t("item_card.subtitles.none");
const track = subtitleTracks.find(
const track = subtitleStreams.find(
(t) => t.Index === selectedOptions?.subtitleIndex,
);
return (
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
);
}, [subtitleTracks, selectedOptions?.subtitleIndex, t]);
}, [subtitleStreams, selectedOptions?.subtitleIndex, t]);
const selectedMediaSourceLabel = useMemo(() => {
const source = selectedOptions?.mediaSource;
@@ -353,7 +389,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
// Determine which option button is the last one (for focus guide targeting)
const lastOptionButton = useMemo(() => {
const hasSubtitleOption =
subtitleTracks.length > 0 ||
subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined;
const hasAudioOption = audioTracks.length > 0;
const hasMediaSourceOption = mediaSources.length > 1;
@@ -363,7 +399,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
if (hasMediaSourceOption) return "mediaSource";
return "quality";
}, [
subtitleTracks.length,
subtitleStreams.length,
selectedOptions?.subtitleIndex,
audioTracks.length,
mediaSources.length,
@@ -651,7 +687,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)}
{/* Subtitle selector */}
{(subtitleTracks.length > 0 ||
{(subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
ref={
@@ -665,10 +701,10 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
showSubtitleModal({
item,
mediaSourceId: selectedOptions?.mediaSource?.Id,
subtitleTracks,
subtitleTracks: subtitleTracksForModal,
currentSubtitleIndex:
selectedOptions?.subtitleIndex ?? -1,
onSubtitleIndexChange: handleSubtitleChange,
onDisableSubtitles: () => handleSubtitleChange(-1),
onServerSubtitleDownloaded:
handleServerSubtitleDownloaded,
refreshSubtitleTracks,

View File

@@ -39,6 +39,7 @@ import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
@@ -138,6 +139,9 @@ export const Controls: FC<Props> = ({
// TV Subtitle Modal hook
const { showSubtitleModal } = useTVSubtitleModal();
// Get subtitle tracks from VideoContext (with proper MPV index mapping)
const { subtitleTracks: videoContextSubtitleTracks } = useVideoContext();
// Track which button should have preferred focus when controls show
type LastModalType = "audio" | "subtitle" | "techInfo" | null;
const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null);
@@ -161,7 +165,7 @@ export const Controls: FC<Props> = ({
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
}, [mediaSource]);
const subtitleTracks = useMemo(() => {
const _subtitleTracks = useMemo(() => {
return (
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? []
);
@@ -183,7 +187,7 @@ export const Controls: FC<Props> = ({
[onAudioIndexChange],
);
const handleSubtitleChange = useCallback(
const _handleSubtitleChange = useCallback(
(index: number) => {
onSubtitleIndexChange?.(index);
},
@@ -374,25 +378,32 @@ export const Controls: FC<Props> = ({
const handleOpenSubtitleSheet = useCallback(() => {
setLastOpenedModal("subtitle");
// Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option
const tracksWithoutDisable = (videoContextSubtitleTracks ?? []).filter(
(track) => track.index !== -1,
);
showSubtitleModal({
item,
mediaSourceId: mediaSource?.Id,
subtitleTracks,
subtitleTracks: tracksWithoutDisable,
currentSubtitleIndex: subtitleIndex ?? -1,
onSubtitleIndexChange: handleSubtitleChange,
onDisableSubtitles: () => {
// Find and call the "Disable" track's setTrack from VideoContext
const disableTrack = videoContextSubtitleTracks?.find(
(t) => t.index === -1,
);
disableTrack?.setTrack();
},
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
refreshSubtitleTracks: onRefreshSubtitleTracks,
});
controlsInteractionRef.current();
}, [
showSubtitleModal,
item,
mediaSource?.Id,
subtitleTracks,
videoContextSubtitleTracks,
subtitleIndex,
handleSubtitleChange,
handleLocalSubtitleDownloaded,
onRefreshSubtitleTracks,
]);
const handleToggleTechnicalInfo = useCallback(() => {

View File

@@ -1,8 +1,6 @@
import type {
BaseItemDto,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import type { Track } from "@/components/video-player/controls/types";
import useRouter from "@/hooks/useAppRouter";
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
import { store } from "@/utils/store";
@@ -10,12 +8,12 @@ import { store } from "@/utils/store";
interface ShowSubtitleModalParams {
item: BaseItemDto;
mediaSourceId?: string | null;
subtitleTracks: MediaStream[];
subtitleTracks: Track[];
currentSubtitleIndex: number;
onSubtitleIndexChange: (index: number) => void;
onDisableSubtitles?: () => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<MediaStream[]>;
refreshSubtitleTracks?: () => Promise<Track[]>;
}
export const useTVSubtitleModal = () => {
@@ -28,7 +26,7 @@ export const useTVSubtitleModal = () => {
mediaSourceId: params.mediaSourceId,
subtitleTracks: params.subtitleTracks,
currentSubtitleIndex: params.currentSubtitleIndex,
onSubtitleIndexChange: params.onSubtitleIndexChange,
onDisableSubtitles: params.onDisableSubtitles,
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
refreshSubtitleTracks: params.refreshSubtitleTracks,

View File

@@ -1,18 +1,16 @@
import type {
BaseItemDto,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { atom } from "jotai";
import type { Track } from "@/components/video-player/controls/types";
export type TVSubtitleModalState = {
item: BaseItemDto;
mediaSourceId?: string | null;
subtitleTracks: MediaStream[];
subtitleTracks: Track[];
currentSubtitleIndex: number;
onSubtitleIndexChange: (index: number) => void;
onDisableSubtitles?: () => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<MediaStream[]>;
refreshSubtitleTracks?: () => Promise<Track[]>;
} | null;
export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null);