feat(tv): add live subtitle track refresh after opensubs download

This commit is contained in:
Fredrik Burmester
2026-01-18 17:44:13 +01:00
parent 0f076d197f
commit f9a3a1f9f6
6 changed files with 165 additions and 40 deletions

View File

@@ -1,6 +1,7 @@
import {
type BaseItemDto,
type MediaSourceInfo,
type MediaStream,
PlaybackOrder,
PlaybackProgressInfo,
RepeatMode,
@@ -269,15 +270,18 @@ export default function page() {
isError: false,
});
// Ref to store the stream fetch function for refreshing subtitle tracks
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
useEffect(() => {
const fetchStreamData = async () => {
const fetchStreamData = async (): Promise<Stream | null> => {
setStreamStatus({ isLoading: true, isError: false });
try {
// Don't attempt to fetch stream data if item is not available
if (!item?.Id) {
console.log("Item not loaded yet, skipping stream data fetch");
setStreamStatus({ isLoading: false, isError: false });
return;
return null;
}
let result: Stream | null = null;
@@ -295,12 +299,12 @@ export default function page() {
if (!api) {
console.warn("API not available for streaming");
setStreamStatus({ isLoading: false, isError: true });
return;
return null;
}
if (!user?.Id) {
console.warn("User not authenticated for streaming");
setStreamStatus({ isLoading: false, isError: true });
return;
return null;
}
// Calculate start ticks directly from item to avoid stale closure
@@ -319,7 +323,7 @@ export default function page() {
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(),
});
if (!res) return;
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
@@ -327,17 +331,22 @@ export default function page() {
t("player.error"),
t("player.failed_to_get_stream_url"),
);
return;
return null;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
return result;
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
return null;
}
};
// Store the fetch function in ref for use by refresh handler
refetchStreamRef.current = fetchStreamData;
fetchStreamData();
}, [
itemId,
@@ -933,16 +942,23 @@ export default function page() {
await videoRef.current?.addSubtitleFile?.(path, true);
}, []);
// TV: Handle server-side subtitle download (needs media source refresh)
// Note: After downloading via Jellyfin API, the subtitle appears in the track list
// but we need to re-fetch the media source to see it. For now, we just log a message.
// A full implementation would refetch getStreamUrl and update the stream state.
const handleServerSubtitleDownloaded = useCallback(() => {
console.log(
"Server-side subtitle downloaded - track list should be refreshed",
);
// TODO: Implement media source refresh to pick up new subtitle
// This would involve re-calling getStreamUrl and updating the stream state
// TV: Refresh subtitle tracks after server-side subtitle download
// Re-fetches the media source to pick up newly downloaded subtitles
const handleRefreshSubtitleTracks = useCallback(async (): Promise<
MediaStream[]
> => {
if (!refetchStreamRef.current) return [];
const newStream = await refetchStreamRef.current();
if (newStream) {
setStream(newStream);
return (
newStream.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
) ?? []
);
}
return [];
}, []);
// TV: Navigate to next item
@@ -1146,7 +1162,7 @@ export default function page() {
nextItem={nextItem}
goToPreviousItem={goToPreviousItem}
goToNextItem={goToNextItem}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
addSubtitleFile={addSubtitleFile}
/>
) : (

View File

@@ -466,12 +466,30 @@ export default function TVSubtitleModal() {
const downloadResult = await downloadAsync(result);
if (downloadResult.type === "server") {
// Give Jellyfin time to process the downloaded subtitle
await new Promise((resolve) => setTimeout(resolve, 5000));
// Refresh tracks and stay open for server-side downloads
if (modalState?.refreshSubtitleTracks) {
const newTracks = await modalState.refreshSubtitleTracks();
// Update atom with new tracks
store.set(tvSubtitleModalAtom, {
...modalState,
subtitleTracks: newTracks,
});
// Switch to tracks tab to show the new subtitle
setActiveTab("tracks");
}
// Also call onServerSubtitleDownloaded to invalidate React Query cache
// (used when opening modal from item detail page)
modalState?.onServerSubtitleDownloaded?.();
// Do NOT close modal - user can see and select the new track
} else if (downloadResult.type === "local" && downloadResult.path) {
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
handleClose(); // Only close for local downloads
}
handleClose();
} catch (error) {
console.error("Failed to download subtitle:", error);
} finally {

View File

@@ -2,16 +2,25 @@ import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
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 {
Animated,
Dimensions,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
@@ -317,6 +326,65 @@ const TVOptionButton = React.forwardRef<
);
});
// Refresh metadata button with spinning animation
const TVRefreshButton: React.FC<{
itemId: string | undefined;
}> = ({ itemId }) => {
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
const spinValue = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (isRefreshing) {
spinValue.setValue(0);
Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true,
}),
).start();
} else {
spinValue.stopAnimation();
spinValue.setValue(0);
}
}, [isRefreshing, spinValue]);
const spin = spinValue.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
});
const handleRefresh = useCallback(async () => {
if (!itemId || isRefreshing) return;
setIsRefreshing(true);
const minSpinTime = new Promise((resolve) => setTimeout(resolve, 1000));
try {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["item", itemId] }),
minSpinTime,
]);
} finally {
setIsRefreshing(false);
}
}, [itemId, queryClient, isRefreshing]);
return (
<TVButton
onPress={handleRefresh}
variant='glass'
square
disabled={isRefreshing}
>
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<Ionicons name='refresh' size={28} color='#FFFFFF' />
</Animated.View>
</TVButton>
);
};
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
({ item, itemWithSources }) => {
@@ -498,12 +566,39 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}
}, [item?.Id, queryClient]);
// Handle manual refresh of metadata
const handleRefreshMetadata = useCallback(() => {
if (item?.Id) {
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
const refreshSubtitleTracks = useCallback(async (): Promise<
MediaStream[]
> => {
if (!api || !item?.Id) return [];
try {
// Fetch fresh item data with media sources
const response = await getUserLibraryApi(api).getItem({
itemId: item.Id,
});
const freshItem = response.data;
const mediaSourceId = selectedOptions?.mediaSource?.Id;
// Find the matching media source
const mediaSource = mediaSourceId
? freshItem.MediaSources?.find(
(s: MediaSourceInfo) => s.Id === mediaSourceId,
)
: freshItem.MediaSources?.[0];
// Return subtitle tracks from the fresh data
return (
mediaSource?.MediaStreams?.filter(
(s: MediaStream) => s.Type === "Subtitle",
) ?? []
);
} catch (error) {
console.error("Failed to refresh subtitle tracks:", error);
return [];
}
}, [item?.Id, queryClient]);
}, [api, item?.Id, selectedOptions?.mediaSource?.Id]);
// Get display values for buttons
const selectedAudioLabel = useMemo(() => {
@@ -829,13 +924,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
: t("common.play")}
</Text>
</TVButton>
<TVButton
onPress={handleRefreshMetadata}
variant='glass'
square
>
<Ionicons name='refresh' size={28} color='#FFFFFF' />
</TVButton>
<TVRefreshButton itemId={item.Id} />
</View>
{/* Playback options */}
@@ -926,6 +1015,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
onSubtitleIndexChange: handleSubtitleChange,
onServerSubtitleDownloaded:
handleServerSubtitleDownloaded,
refreshSubtitleTracks,
})
}
/>

View File

@@ -75,7 +75,9 @@ interface Props {
nextItem?: BaseItemDto | null;
goToPreviousItem?: () => void;
goToNextItem?: () => void;
onServerSubtitleDownloaded?: () => void;
onRefreshSubtitleTracks?: () => Promise<
import("@jellyfin/sdk/lib/generated-client").MediaStream[]
>;
addSubtitleFile?: (path: string) => void;
}
@@ -311,7 +313,7 @@ export const Controls: FC<Props> = ({
nextItem: nextItemProp,
goToPreviousItem,
goToNextItem: goToNextItemProp,
onServerSubtitleDownloaded,
onRefreshSubtitleTracks,
addSubtitleFile,
}) => {
const insets = useSafeAreaInsets();
@@ -484,10 +486,6 @@ export const Controls: FC<Props> = ({
controlsInteractionRef.current();
}, [showOptions, t, audioOptions, handleAudioChange]);
const handleServerSubtitleDownloaded = useCallback(() => {
onServerSubtitleDownloaded?.();
}, [onServerSubtitleDownloaded]);
const handleLocalSubtitleDownloaded = useCallback(
(path: string) => {
addSubtitleFile?.(path);
@@ -503,8 +501,8 @@ export const Controls: FC<Props> = ({
subtitleTracks,
currentSubtitleIndex: subtitleIndex ?? -1,
onSubtitleIndexChange: handleSubtitleChange,
onServerSubtitleDownloaded: handleServerSubtitleDownloaded,
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
refreshSubtitleTracks: onRefreshSubtitleTracks,
});
controlsInteractionRef.current();
}, [
@@ -514,8 +512,8 @@ export const Controls: FC<Props> = ({
subtitleTracks,
subtitleIndex,
handleSubtitleChange,
handleServerSubtitleDownloaded,
handleLocalSubtitleDownloaded,
onRefreshSubtitleTracks,
]);
const effectiveProgress = useSharedValue(0);

View File

@@ -15,6 +15,7 @@ interface ShowSubtitleModalParams {
onSubtitleIndexChange: (index: number) => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<MediaStream[]>;
}
export const useTVSubtitleModal = () => {
@@ -30,6 +31,7 @@ export const useTVSubtitleModal = () => {
onSubtitleIndexChange: params.onSubtitleIndexChange,
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
refreshSubtitleTracks: params.refreshSubtitleTracks,
});
router.push("/(auth)/tv-subtitle-modal");
},

View File

@@ -12,6 +12,7 @@ export type TVSubtitleModalState = {
onSubtitleIndexChange: (index: number) => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<MediaStream[]>;
} | null;
export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null);