From 2c27186e2268cd81898f7aeb25f7a74fbea7b7ad Mon Sep 17 00:00:00 2001 From: Uruk Date: Mon, 9 Feb 2026 21:43:33 +0100 Subject: [PATCH] Fix: Improves Chromecast casting experience Fixes several issues and enhances the Chromecast casting experience: - Prevents errors when loading media by slimming down the customData payload to avoid exceeding message size limits. - Improves logic for selecting custom data from media status. - Fixes an issue with subtitle track selection. - Recommends stereo audio tracks for better Chromecast compatibility. - Improves volume control and mute synchronization between the app and the Chromecast device. - Adds error handling for `loadMedia` in `PlayButton`. - Fixes image caching issue for season posters in mini player. - Implements cleanup for scroll retry timeout in episode list. - Ensures segment skipping functions are asynchronous. - Resets `hasReportedStartRef` after stopping casting. - Prevents seeking past the end of Outro segments. - Reports playback progress more accurately by also taking player state changes into account. --- app/(auth)/casting-player.tsx | 34 +++-- components/Chromecast.tsx | 10 +- components/PlayButton.tsx | 3 + components/casting/CastingMiniPlayer.tsx | 5 +- .../chromecast/ChromecastConnectionMenu.tsx | 10 +- .../chromecast/ChromecastDeviceSheet.tsx | 4 +- .../chromecast/ChromecastEpisodeList.tsx | 11 ++ .../chromecast/hooks/useChromecastSegments.ts | 12 +- hooks/useCasting.ts | 1 + hooks/useSegmentSkipper.ts | 6 +- utils/casting/mediaInfo.ts | 124 +++++++++++------- 11 files changed, 147 insertions(+), 73 deletions(-) diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index a9429dc2..0d9ca9ef 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -156,8 +156,12 @@ export default function CastingPlayerScreen() { // Priority 2: Try customData from mediaStatus const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null; - if (customData?.Type && customData.Type !== "Movie") { - // Only use customData if it has a real Type (not default fallback) + if ( + customData?.Type && + (customData.ImageTags || customData.MediaSources || customData.Id) + ) { + // Use customData if it has a real Type AND meaningful metadata + // (rules out placeholder objects that lack image tags, media sources, or an ID) return customData; } @@ -265,7 +269,9 @@ export default function CastingPlayerScreen() { userId: user.Id, audioStreamIndex: options.audioIndex ?? selectedAudioTrackIndex ?? undefined, - subtitleStreamIndex: options.subtitleIndex ?? undefined, + // null = subtitles off (omit from request), number = specific track + subtitleStreamIndex: + options.subtitleIndex === null ? undefined : options.subtitleIndex, maxStreamingBitrate: options.bitrateValue, }); @@ -447,26 +453,32 @@ export default function CastingPlayerScreen() { // Track whether user has manually selected an audio track const [userSelectedAudio, setUserSelectedAudio] = useState(false); - // Auto-select stereo audio track for better Chromecast compatibility - // Note: This only updates the UI state. The actual audio track change requires - // regenerating the stream URL, which would be disruptive on initial load. - // The user can manually switch audio tracks if needed. + // Detect recommended stereo track for Chromecast compatibility. + // Does NOT mutate selectedAudioTrackIndex — UI can show a badge instead. + // TODO: Use recommendedAudioTrackIndex in UI to show a "stereo recommended" badge + const [_recommendedAudioTrackIndex, setRecommendedAudioTrackIndex] = useState< + number | null + >(null); + useEffect(() => { - if (!remoteMediaClient || !mediaStatus?.mediaInfo || userSelectedAudio) + if (!remoteMediaClient || !mediaStatus?.mediaInfo || userSelectedAudio) { + setRecommendedAudioTrackIndex(null); return; + } const currentTrack = availableAudioTracks.find( (t) => t.index === selectedAudioTrackIndex, ); - // If current track is 5.1+ audio, suggest stereo in the UI + // If current track is 5.1+ audio, recommend stereo alternative if (currentTrack && (currentTrack.channels || 0) > 2) { const stereoTrack = availableAudioTracks.find((t) => t.channels === 2); if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) { - // Auto-select stereo in UI (user can manually trigger reload) - setSelectedAudioTrackIndex(stereoTrack.index); + setRecommendedAudioTrackIndex(stereoTrack.index); + return; } } + setRecommendedAudioTrackIndex(null); }, [ mediaStatus?.mediaInfo, availableAudioTracks, diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index d8fadbd2..10383744 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -41,6 +41,7 @@ export function Chromecast({ const isConnected = castState === CastState.CONNECTED; const lastReportedProgressRef = useRef(0); + const lastReportedPlayerStateRef = useRef(null); const playSessionIdRef = useRef(null); const lastContentIdRef = useRef(null); const discoveryAttempts = useRef(0); @@ -116,9 +117,13 @@ export function Chromecast({ } const streamPosition = mediaStatus.streamPosition || 0; + const playerState = mediaStatus.playerState || null; - // Report every 10 seconds - if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) { + // Report every 10 seconds OR immediately when playerState changes (pause/resume) + const positionChanged = + Math.abs(streamPosition - lastReportedProgressRef.current) >= 10; + const stateChanged = playerState !== lastReportedPlayerStateRef.current; + if (!positionChanged && !stateChanged) { return; } @@ -147,6 +152,7 @@ export function Chromecast({ .reportPlaybackProgress({ playbackProgressInfo: progressInfo }) .then(() => { lastReportedProgressRef.current = streamPosition; + lastReportedPlayerStateRef.current = playerState; }) .catch((error) => { console.error("Failed to report Chromecast progress:", error); diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 329f3e00..def898c7 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -210,6 +210,9 @@ export const PlayButton: React.FC = ({ return; } router.push("/casting-player"); + }) + .catch((err) => { + console.error("[PlayButton] loadMedia failed:", err); }); } catch (e) { console.error("[PlayButton] Cast error:", e); diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index 5cdbd28a..9d53c34f 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -127,8 +127,9 @@ export const CastingMiniPlayer: React.FC = () => { currentItem.ParentIndexNumber !== undefined && currentItem.SeasonId ) { - // Build season poster URL using SeriesId and SeasonId as tag - return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`; + // Build season poster URL using SeriesId and image tag for cache validation + const imageTag = currentItem.ImageTags?.Primary || ""; + return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${imageTag ? `&tag=${imageTag}` : ""}`; } // For non-episodes, use item's own poster diff --git a/components/chromecast/ChromecastConnectionMenu.tsx b/components/chromecast/ChromecastConnectionMenu.tsx index dcbc3055..05c1c2ee 100644 --- a/components/chromecast/ChromecastConnectionMenu.tsx +++ b/components/chromecast/ChromecastConnectionMenu.tsx @@ -32,6 +32,7 @@ export const ChromecastConnectionMenu: React.FC< // Volume state - use refs to avoid triggering re-renders during sliding const [displayVolume, setDisplayVolume] = useState(50); const [isMuted, setIsMuted] = useState(false); + const isMutedRef = useRef(false); const volumeValue = useSharedValue(50); const minimumValue = useSharedValue(0); const maximumValue = useSharedValue(100); @@ -55,6 +56,7 @@ export const ChromecastConnectionMenu: React.FC< lastSetVolume.current = percent; } const muted = await castSession.isMute(); + isMutedRef.current = muted; setIsMuted(muted); } catch { // Ignore errors @@ -78,7 +80,8 @@ export const ChromecastConnectionMenu: React.FC< } } const muted = await castSession.isMute(); - if (muted !== isMuted) { + if (muted !== isMutedRef.current) { + isMutedRef.current = muted; setIsMuted(muted); } } catch { @@ -87,7 +90,7 @@ export const ChromecastConnectionMenu: React.FC< }, 1000); // Poll less frequently return () => clearInterval(interval); - }, [visible, castSession, volumeValue, isMuted]); + }, [visible, castSession, volumeValue]); // Volume change during sliding - update display only, don't call API const handleVolumeChange = useCallback((value: number) => { @@ -120,6 +123,7 @@ export const ChromecastConnectionMenu: React.FC< try { const newMute = !isMuted; await castSession.setMute(newMute); + isMutedRef.current = newMute; setIsMuted(newMute); } catch (error) { console.error("[Connection Menu] Mute error:", error); @@ -259,6 +263,7 @@ export const ChromecastConnectionMenu: React.FC< volumeValue.value = value; handleVolumeChange(value); if (isMuted) { + isMutedRef.current = false; setIsMuted(false); try { await castSession?.setMute(false); @@ -267,6 +272,7 @@ export const ChromecastConnectionMenu: React.FC< "[ChromecastConnectionMenu] Failed to unmute:", error, ); + isMutedRef.current = true; setIsMuted(true); // Rollback on failure } } diff --git a/components/chromecast/ChromecastDeviceSheet.tsx b/components/chromecast/ChromecastDeviceSheet.tsx index 184c818e..99455c5e 100644 --- a/components/chromecast/ChromecastDeviceSheet.tsx +++ b/components/chromecast/ChromecastDeviceSheet.tsx @@ -282,10 +282,10 @@ export const ChromecastDeviceSheet: React.FC = ({ volumeValue.value = value; handleVolumeChange(value); // Unmute when adjusting volume - if (isMuted) { + if (isMuted && castSession) { setIsMuted(false); try { - await castSession?.setMute(false); + await castSession.setMute(false); } catch (error) { console.error("[Volume] Failed to unmute:", error); setIsMuted(true); // Rollback on failure diff --git a/components/chromecast/ChromecastEpisodeList.tsx b/components/chromecast/ChromecastEpisodeList.tsx index 7b6c7ef1..25409aa1 100644 --- a/components/chromecast/ChromecastEpisodeList.tsx +++ b/components/chromecast/ChromecastEpisodeList.tsx @@ -40,6 +40,17 @@ export const ChromecastEpisodeList: React.FC = ({ const scrollRetryTimeoutRef = useRef(null); const MAX_SCROLL_RETRIES = 3; + // Cleanup pending retry timeout on unmount + useEffect(() => { + return () => { + if (scrollRetryTimeoutRef.current) { + clearTimeout(scrollRetryTimeoutRef.current); + scrollRetryTimeoutRef.current = null; + } + scrollRetryCountRef.current = 0; + }; + }, []); + // Get unique seasons from episodes const seasons = useMemo(() => { const seasonSet = new Set(); diff --git a/components/chromecast/hooks/useChromecastSegments.ts b/components/chromecast/hooks/useChromecastSegments.ts index 4b94b6af..4fdec00b 100644 --- a/components/chromecast/hooks/useChromecastSegments.ts +++ b/components/chromecast/hooks/useChromecastSegments.ts @@ -105,27 +105,27 @@ export const useChromecastSegments = ( // Skip functions const skipIntro = useCallback( - (seekFn: (positionMs: number) => Promise) => { + async (seekFn: (positionMs: number) => Promise): Promise => { if (segments.intro) { - return seekFn(segments.intro.end * 1000); + await seekFn(segments.intro.end * 1000); } }, [segments.intro], ); const skipCredits = useCallback( - (seekFn: (positionMs: number) => Promise) => { + async (seekFn: (positionMs: number) => Promise): Promise => { if (segments.credits) { - return seekFn(segments.credits.end * 1000); + await seekFn(segments.credits.end * 1000); } }, [segments.credits], ); const skipSegment = useCallback( - (seekFn: (positionMs: number) => Promise) => { + async (seekFn: (positionMs: number) => Promise): Promise => { if (currentSegment?.segment) { - return seekFn(currentSegment.segment.end * 1000); + await seekFn(currentSegment.segment.end * 1000); } }, [currentSegment], diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index b8b2ed46..7a55605f 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -307,6 +307,7 @@ export const useCasting = (item: BaseItemDto | null) => { } catch (error) { console.error("[useCasting] Error during stop:", error); } finally { + hasReportedStartRef.current = null; setState(DEFAULT_CAST_STATE); stateRef.current = DEFAULT_CAST_STATE; diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts index 57c8dcc1..1b44c13e 100644 --- a/hooks/useSegmentSkipper.ts +++ b/hooks/useSegmentSkipper.ts @@ -66,7 +66,11 @@ export const useSegmentSkipper = ({ if (!currentSegment || skipMode === "none") return; // For Outro segments, prevent seeking past the end - if (segmentType === "Outro" && totalDuration) { + if ( + segmentType === "Outro" && + totalDuration != null && + Number.isFinite(totalDuration) + ) { const seekTime = Math.min(currentSegment.endTime, totalDuration); seek(seekTime); } else { diff --git a/utils/casting/mediaInfo.ts b/utils/casting/mediaInfo.ts index 73224583..c0eae0a1 100644 --- a/utils/casting/mediaInfo.ts +++ b/utils/casting/mediaInfo.ts @@ -11,15 +11,26 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; /** * Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`. + * + * NOTE on contentType: Chromecast Default Media Receiver auto-detects HLS/DASH + * from the URL. Setting contentType to "application/x-mpegurl" or "application/dash+xml" + * actually BREAKS playback on many receivers. Always use "video/mp4" unless + * you have a custom receiver that explicitly handles other MIME types. */ export const buildCastMediaInfo = ({ item, streamUrl, api, + contentType, + isLive = false, }: { item: BaseItemDto; streamUrl: string; api: Api; + /** Override MIME type. Defaults to "video/mp4" which works for all stream types on Default Media Receiver. */ + contentType?: string; + /** Set true for live TV streams to use MediaStreamType.LIVE. */ + isLive?: boolean; }) => { if (!item.Id) { throw new Error("Missing item.Id for media load — cannot build contentId"); @@ -33,58 +44,77 @@ export const buildCastMediaInfo = ({ const buildImages = (urls: (string | null | undefined)[]) => urls.filter(Boolean).map((url) => ({ url: url as string })); - const metadata = - item.Type === "Episode" - ? { - type: "tvShow" as const, - title: item.Name || "", - episodeNumber: item.IndexNumber || 0, - seasonNumber: item.ParentIndexNumber || 0, - seriesTitle: item.SeriesName || "", - images: buildImages([ - getParentBackdropImageUrl({ - api, - item, - quality: 90, - width: 2000, - }), - ]), - } - : item.Type === "Movie" - ? { - type: "movie" as const, - title: item.Name || "", - subtitle: item.Overview || "", - images: buildImages([ - getPrimaryImageUrl({ - api, - item, - quality: 90, - width: 2000, - }), - ]), - } - : { - type: "generic" as const, - title: item.Name || "", - subtitle: item.Overview || "", - images: buildImages([ - getPrimaryImageUrl({ - api, - item, - quality: 90, - width: 2000, - }), - ]), - }; + const buildItemMetadata = () => { + if (item.Type === "Episode") { + return { + type: "tvShow" as const, + title: item.Name || "", + episodeNumber: item.IndexNumber || 0, + seasonNumber: item.ParentIndexNumber || 0, + seriesTitle: item.SeriesName || "", + images: buildImages([ + getParentBackdropImageUrl({ api, item, quality: 90, width: 2000 }), + ]), + }; + } + + if (item.Type === "Movie") { + return { + type: "movie" as const, + title: item.Name || "", + subtitle: item.Overview || "", + images: buildImages([ + getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }), + ]), + }; + } + + return { + type: "generic" as const, + title: item.Name || "", + subtitle: item.Overview || "", + images: buildImages([ + getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }), + ]), + }; + }; + + const metadata = buildItemMetadata(); + + // Build a slim customData payload with only the fields the casting-player needs. + // Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit, + // especially for movies with many chapters, media sources, and people. + const slimCustomData: Partial = { + Id: item.Id, + Name: item.Name, + Type: item.Type, + SeriesName: item.SeriesName, + SeriesId: item.SeriesId, + SeasonId: item.SeasonId, + IndexNumber: item.IndexNumber, + ParentIndexNumber: item.ParentIndexNumber, + ImageTags: item.ImageTags, + RunTimeTicks: item.RunTimeTicks, + Overview: item.Overview, + MediaStreams: item.MediaStreams, + MediaSources: item.MediaSources?.map((src) => ({ + Id: src.Id, + Bitrate: src.Bitrate, + Container: src.Container, + Name: src.Name, + })), + UserData: item.UserData + ? { PlaybackPositionTicks: item.UserData.PlaybackPositionTicks } + : undefined, + }; return { contentId: itemId, contentUrl: streamUrl, - contentType: "video/mp4", - streamType: MediaStreamType.BUFFERED, + contentType: contentType || "video/mp4", + streamType: isLive ? MediaStreamType.LIVE : MediaStreamType.BUFFERED, streamDuration, - customData: item, + customData: slimCustomData, metadata, }; };