From f9a3a1f9f6d16c4a2115d5b1d1ba162199d410f2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 18 Jan 2026 17:44:13 +0100 Subject: [PATCH] feat(tv): add live subtitle track refresh after opensubs download --- app/(auth)/player/direct-player.tsx | 50 +++++--- app/(auth)/tv-subtitle-modal.tsx | 22 +++- components/ItemContent.tv.tsx | 116 ++++++++++++++++-- .../video-player/controls/Controls.tv.tsx | 14 +-- hooks/useTVSubtitleModal.ts | 2 + utils/atoms/tvSubtitleModal.ts | 1 + 6 files changed, 165 insertions(+), 40 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 8614ad22..d786bc3d 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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) | null>(null); + useEffect(() => { - const fetchStreamData = async () => { + const fetchStreamData = async (): Promise => { 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} /> ) : ( diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index c038cf2c..27b3fe3c 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -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 { diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index c8bb76cb..7a4322ec 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -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 ( + + + + + + ); +}; + // Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { @@ -498,12 +566,39 @@ export const ItemContentTV: React.FC = 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 = React.memo( : t("common.play")} - - - + {/* Playback options */} @@ -926,6 +1015,7 @@ export const ItemContentTV: React.FC = React.memo( onSubtitleIndexChange: handleSubtitleChange, onServerSubtitleDownloaded: handleServerSubtitleDownloaded, + refreshSubtitleTracks, }) } /> diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index b669a142..7d88f9e5 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -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 = ({ nextItem: nextItemProp, goToPreviousItem, goToNextItem: goToNextItemProp, - onServerSubtitleDownloaded, + onRefreshSubtitleTracks, addSubtitleFile, }) => { const insets = useSafeAreaInsets(); @@ -484,10 +486,6 @@ export const Controls: FC = ({ 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 = ({ subtitleTracks, currentSubtitleIndex: subtitleIndex ?? -1, onSubtitleIndexChange: handleSubtitleChange, - onServerSubtitleDownloaded: handleServerSubtitleDownloaded, onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, + refreshSubtitleTracks: onRefreshSubtitleTracks, }); controlsInteractionRef.current(); }, [ @@ -514,8 +512,8 @@ export const Controls: FC = ({ subtitleTracks, subtitleIndex, handleSubtitleChange, - handleServerSubtitleDownloaded, handleLocalSubtitleDownloaded, + onRefreshSubtitleTracks, ]); const effectiveProgress = useSharedValue(0); diff --git a/hooks/useTVSubtitleModal.ts b/hooks/useTVSubtitleModal.ts index f1e8fcb3..1e9df927 100644 --- a/hooks/useTVSubtitleModal.ts +++ b/hooks/useTVSubtitleModal.ts @@ -15,6 +15,7 @@ interface ShowSubtitleModalParams { onSubtitleIndexChange: (index: number) => void; onServerSubtitleDownloaded?: () => void; onLocalSubtitleDownloaded?: (path: string) => void; + refreshSubtitleTracks?: () => Promise; } 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"); }, diff --git a/utils/atoms/tvSubtitleModal.ts b/utils/atoms/tvSubtitleModal.ts index ac68ebfd..1fbb900a 100644 --- a/utils/atoms/tvSubtitleModal.ts +++ b/utils/atoms/tvSubtitleModal.ts @@ -12,6 +12,7 @@ export type TVSubtitleModalState = { onSubtitleIndexChange: (index: number) => void; onServerSubtitleDownloaded?: () => void; onLocalSubtitleDownloaded?: (path: string) => void; + refreshSubtitleTracks?: () => Promise; } | null; export const tvSubtitleModalAtom = atom(null);