From 56e350891d7307bc5360ed0653d5c4d9f279cfcc Mon Sep 17 00:00:00 2001 From: Uruk Date: Sat, 23 May 2026 23:27:33 +0200 Subject: [PATCH] feat(casting): mount the autoplay watcher and countdown overlay --- app/(auth)/(tabs)/_layout.tsx | 2 + app/(auth)/casting-player.tsx | 97 +++++++++++++++++++++- components/casting/CastAutoplayWatcher.tsx | 12 +++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 components/casting/CastAutoplayWatcher.tsx diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 0d6749c6a..343c57368 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -11,6 +11,7 @@ import { withLayoutContext } from "expo-router"; import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; +import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher"; import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer"; import { MiniPlayerBar } from "@/components/music/MiniPlayerBar"; import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine"; @@ -120,6 +121,7 @@ export default function TabLayout() { /> + diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 55da55a7c..8c835d399 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -4,7 +4,7 @@ */ import { router, Stack } from "expo-router"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, ScrollView, View } from "react-native"; @@ -32,6 +32,7 @@ import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisode import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu"; import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; import { Text } from "@/components/common/Text"; +import { AutoplayCountdown } from "@/components/player/AutoplayCountdown"; import { useCastDismissGesture } from "@/hooks/useCastDismissGesture"; import { useCastEpisodes } from "@/hooks/useCastEpisodes"; import { useCasting } from "@/hooks/useCasting"; @@ -39,6 +40,7 @@ import { useCastPlayerItem } from "@/hooks/useCastPlayerItem"; import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress"; import { useCastSelection } from "@/hooks/useCastSelection"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { castAutoplayAtom } from "@/utils/atoms/castAutoplay"; import { useSettings } from "@/utils/atoms/settings"; import { detectCapabilities } from "@/utils/casting/capabilities"; import { loadCastMedia } from "@/utils/casting/castLoad"; @@ -55,9 +57,14 @@ export default function CastingPlayerScreen() { const insets = useSafeAreaInsets(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); - const { settings } = useSettings(); + const { settings, updateSettings } = useSettings(); const { t } = useTranslation(); + // Chromecast autoplay countdown — watcher hook drives this atom; we render + // the overlay here when set, and handle Play-now / Cancel from the user. + const castAutoplay = useAtomValue(castAutoplayAtom); + const setCastAutoplay = useSetAtom(castAutoplayAtom); + // Get raw Chromecast state directly - same as old implementation const castState = useCastState(); const mediaStatus = useMediaStatus(); @@ -342,6 +349,67 @@ export default function CastingPlayerScreen() { })); }, [selectedSource, fetchedItem?.MediaStreams]); + // Autoplay overlay's "Play now" — load the queued next episode immediately. + // Mirrors `useCastEpisodes.loadEpisode` exactly (same `loadCastMedia` shape, + // same start-position derivation) so the cast load is identical regardless + // of whether it is triggered by the user or by the countdown timer. + const onAutoplayPlayNow = useCallback(async () => { + if (!castAutoplay) return; + const episode = castAutoplay.nextEpisode; + if (!api || !user?.Id || !remoteMediaClient || !episode?.Id) { + setCastAutoplay(null); + return; + } + try { + const startPositionMs = + (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000; + const result = await loadCastMedia({ + client: remoteMediaClient, + device: castDevice, + api, + item: episode, + userId: user.Id, + profileMode: settings.chromecastProfile, + maxBitrateSetting: settings.chromecastMaxBitrate, + options: { startPositionMs }, + }); + if (!result.ok) { + console.error( + "[Casting Player] Failed to load next episode (play now):", + result.error, + ); + return; + } + // Reset the autoplay counter on explicit user action. + updateSettings({ autoPlayEpisodeCount: 0 }); + } catch (error) { + console.error( + "[Casting Player] Failed to load next episode (play now):", + error, + ); + } finally { + setCastAutoplay(null); + } + }, [ + castAutoplay, + api, + user?.Id, + remoteMediaClient, + castDevice, + settings.chromecastProfile, + settings.chromecastMaxBitrate, + updateSettings, + setCastAutoplay, + ]); + + // Poster URL for the queued next episode (mirrors `posterUrl` for the + // currently-playing item — same helper, same dimensions). + const autoplayPosterUrl = useMemo(() => { + if (!castAutoplay || !api?.basePath) return null; + const ep = castAutoplay.nextEpisode; + return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390); + }, [castAutoplay, api?.basePath]); + // NOTE: Auto-navigation to casting-player is handled by higher-level // components (e.g., CastingMiniPlayer or Chromecast button). We intentionally // do NOT call router.replace("/casting-player") here because this component @@ -570,6 +638,31 @@ export default function CastingPlayerScreen() { /> + {/* Autoplay countdown overlay — bottom-centred above the episode + control row and main controls. 320 wide card; centred via + left/right:0 + alignItems:"center". */} + {castAutoplay && ( + + setCastAutoplay(null)} + /> + + )} + {/* Modals */}