From 23b4f20d18a899a6ea72821e594ed8a9b10fd15f Mon Sep 17 00:00:00 2001 From: Uruk Date: Fri, 22 May 2026 00:05:14 +0200 Subject: [PATCH] fix(casting): track menus from full item, keep quality on version switch, stable resume position Co-Authored-By: Claude Opus 4.7 --- app/(auth)/casting-player.tsx | 47 ++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index d0d9fae1d..2a7ddaa73 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -94,6 +94,9 @@ export default function CastingPlayerScreen() { const lastSyncPositionRef = useRef(0); const lastSyncTimestampRef = useRef(Date.now()); + // Last stable playback position (seconds), for resuming across reloads. + const resumePositionRef = useRef(0); + // Fetch full item data from Jellyfin by ID const [fetchedItem, setFetchedItem] = useState(null); @@ -149,6 +152,14 @@ export default function CastingPlayerScreen() { return () => clearInterval(interval); }, [mediaStatus?.playerState, mediaStatus?.streamPosition]); + // Track the last stable position so a reload mid-switch resumes correctly. + useEffect(() => { + const pos = mediaStatus?.streamPosition ?? 0; + if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) { + resumePositionRef.current = pos; + } + }, [mediaStatus?.streamPosition, mediaStatus?.playerState]); + // Extract item from customData, or use fetched item, or create a minimal fallback const currentItem = useMemo(() => { // Priority 1: Use fetched item from API (most reliable) @@ -252,7 +263,7 @@ export default function CastingPlayerScreen() { console.warn("[Casting Player] Cannot reload - missing required data"); return false; } - const currentPosition = mediaStatus?.streamPosition ?? 0; + const currentPosition = resumePositionRef.current; const result = await loadCastMedia({ client: remoteMediaClient, device: castDevice, @@ -284,7 +295,6 @@ export default function CastingPlayerScreen() { currentItem, remoteMediaClient, castDevice, - mediaStatus?.streamPosition, settings.chromecastProfile, settings.chromecastMaxBitrate, ], @@ -365,24 +375,26 @@ export default function CastingPlayerScreen() { }, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]); // The MediaSource currently selected, for deriving its tracks. + // Derived from fetchedItem: the slim cast-customData item strips per-source + // MediaStreams, so only the full fetched item yields correct track lists. const selectedSource = useMemo( () => - currentItem?.MediaSources?.find( + fetchedItem?.MediaSources?.find( (s) => s.Id === currentSelection?.mediaSourceId, ) ?? - currentItem?.MediaSources?.[0] ?? + fetchedItem?.MediaSources?.[0] ?? null, - [currentItem?.MediaSources, currentSelection?.mediaSourceId], + [fetchedItem?.MediaSources, currentSelection?.mediaSourceId], ); // Real alternate versions (multi-version items). const availableVersions = useMemo( () => - (currentItem?.MediaSources ?? []).map((s, i) => ({ + (fetchedItem?.MediaSources ?? []).map((s, i) => ({ id: s.Id ?? `source-${i}`, name: s.Name || `${t("casting_player.version")} ${i + 1}`, })), - [currentItem?.MediaSources, t], + [fetchedItem?.MediaSources, t], ); // Quality tiers from the shared ladder, capped to BOTH the device's @@ -395,7 +407,7 @@ export default function CastingPlayerScreen() { }); const mediaBitrate = selectedSource?.Bitrate ?? - currentItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ?? + fetchedItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ?? Number.POSITIVE_INFINITY; const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate); return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling); @@ -404,11 +416,11 @@ export default function CastingPlayerScreen() { settings.chromecastProfile, settings.chromecastMaxBitrate, selectedSource, - currentItem?.MediaStreams, + fetchedItem?.MediaStreams, ]); const availableAudioTracks = useMemo(() => { - const streams = selectedSource?.MediaStreams ?? currentItem?.MediaStreams; + const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams; if (!streams) return []; return streams .filter((stream) => stream.Type === "Audio") @@ -422,10 +434,10 @@ export default function CastingPlayerScreen() { channels: stream.Channels, bitrate: stream.BitRate, })); - }, [selectedSource, currentItem?.MediaStreams]); + }, [selectedSource, fetchedItem?.MediaStreams]); const availableSubtitleTracks = useMemo(() => { - const streams = selectedSource?.MediaStreams ?? currentItem?.MediaStreams; + const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams; if (!streams) return []; return streams .filter((stream) => stream.Type === "Subtitle") @@ -443,7 +455,7 @@ export default function CastingPlayerScreen() { isForced: stream.IsForced || false, isExternal: stream.IsExternal || false, })); - }, [selectedSource, currentItem?.MediaStreams]); + }, [selectedSource, fetchedItem?.MediaStreams]); // Fetch episodes for TV shows useEffect(() => { @@ -1384,10 +1396,11 @@ export default function CastingPlayerScreen() { versions={availableVersions} selectedVersionId={currentSelection?.mediaSourceId ?? ""} onVersionChange={(id) => { - if (!currentItem) return; - applySelection( - resolveSelection(currentItem, { mediaSourceId: id }), - ); + if (!fetchedItem) return; + applySelection({ + ...resolveSelection(fetchedItem, { mediaSourceId: id }), + maxBitrate: currentSelection?.maxBitrate, + }); }} qualities={availableQualities} selectedMaxBitrate={currentSelection?.maxBitrate}