mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-23 11:38:03 +00:00
fix(tv): resolve subtitle selector index mismatch using VideoContext tracks
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user