From be2fd53f312f97d9f28232032bccc6636f2ba25a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 08:29:57 +0100 Subject: [PATCH] fix(tv): resolve subtitle selector index mismatch using VideoContext tracks --- app/(auth)/tv-subtitle-modal.tsx | 26 +++---- components/ItemContent.tv.tsx | 70 ++++++++++++++----- .../video-player/controls/Controls.tv.tsx | 27 ++++--- hooks/useTVSubtitleModal.ts | 14 ++-- utils/atoms/tvSubtitleModal.ts | 12 ++-- 5 files changed, 97 insertions(+), 52 deletions(-) diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index 8bc0cc07..7167f7ba 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -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)} /> ))} diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index ac4fad32..999d6037 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -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 = 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 = 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 = 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 => { if (!api || !item?.Id) return []; try { @@ -262,12 +288,22 @@ export const ItemContentTV: React.FC = 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 = 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 = 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 = 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 = React.memo( )} {/* Subtitle selector */} - {(subtitleTracks.length > 0 || + {(subtitleStreams.length > 0 || selectedOptions?.subtitleIndex !== undefined) && ( = React.memo( showSubtitleModal({ item, mediaSourceId: selectedOptions?.mediaSource?.Id, - subtitleTracks, + subtitleTracks: subtitleTracksForModal, currentSubtitleIndex: selectedOptions?.subtitleIndex ?? -1, - onSubtitleIndexChange: handleSubtitleChange, + onDisableSubtitles: () => handleSubtitleChange(-1), onServerSubtitleDownloaded: handleServerSubtitleDownloaded, refreshSubtitleTracks, diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 6415b077..01f4ad81 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -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 = ({ // 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(null); @@ -161,7 +165,7 @@ export const Controls: FC = ({ 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 = ({ [onAudioIndexChange], ); - const handleSubtitleChange = useCallback( + const _handleSubtitleChange = useCallback( (index: number) => { onSubtitleIndexChange?.(index); }, @@ -374,25 +378,32 @@ export const Controls: FC = ({ 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(() => { diff --git a/hooks/useTVSubtitleModal.ts b/hooks/useTVSubtitleModal.ts index 1e9df927..38d44223 100644 --- a/hooks/useTVSubtitleModal.ts +++ b/hooks/useTVSubtitleModal.ts @@ -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; + refreshSubtitleTracks?: () => Promise; } 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, diff --git a/utils/atoms/tvSubtitleModal.ts b/utils/atoms/tvSubtitleModal.ts index 1fbb900a..3a940c12 100644 --- a/utils/atoms/tvSubtitleModal.ts +++ b/utils/atoms/tvSubtitleModal.ts @@ -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; + refreshSubtitleTracks?: () => Promise; } | null; export const tvSubtitleModalAtom = atom(null);