mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-06 17:56:17 +00:00
feat(tv): add live subtitle track refresh after opensubs download
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user