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

@@ -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(() => {