From 2c2a7137d35f11eecbd3ac76ac587a53c73afb17 Mon Sep 17 00:00:00 2001 From: Uruk Date: Sat, 23 May 2026 23:36:38 +0200 Subject: [PATCH] fix(autoplay): make Cancel stop the timer and fix stale cast capture state --- app/(auth)/casting-player.tsx | 3 ++ .../video-player/controls/BottomControls.tsx | 22 +++++++++--- hooks/useCastAutoplay.ts | 36 ++++++++++++++----- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 8c835d399..a2d0f4df4 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -407,6 +407,9 @@ export default function CastingPlayerScreen() { const autoplayPosterUrl = useMemo(() => { if (!castAutoplay || !api?.basePath) return null; const ep = castAutoplay.nextEpisode; + // `BaseItemDto.Id` is `string | undefined`; bail if missing so we never + // call the helper with `undefined`. AutoplayCountdown handles null. + if (!ep?.Id) return null; return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390); }, [castAutoplay, api?.basePath]); diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index f5b2bbae3..a7dcf1249 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -147,14 +147,22 @@ export const BottomControls: FC = ({ autoPlayHandlerRef.current = handleNextEpisodeAutoPlay; useEffect(() => { - if (!showNextEpisodeCountdown) { - // Show-condition flipped off: clear the timer and reset for the next episode. + if (!showNextEpisodeCountdown || autoplayCancelled) { + // Either the show-condition flipped off OR the user cancelled. + // In both cases, stop the running timer immediately so autoplay + // can't fire after Cancel was pressed. if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } - setAutoplayCancelled(false); - setSecondsRemaining(settings.autoplayCountdownSeconds); + // Only reset cancellation + seconds when the show-condition itself + // flipped off — a fresh credits/end-of-video window then starts a + // brand-new countdown. If we got here because autoplayCancelled + // just flipped true, keep it true so the countdown stays stopped. + if (!showNextEpisodeCountdown) { + setAutoplayCancelled(false); + setSecondsRemaining(settings.autoplayCountdownSeconds); + } return; } @@ -179,7 +187,11 @@ export const BottomControls: FC = ({ intervalRef.current = null; } }; - }, [showNextEpisodeCountdown, settings.autoplayCountdownSeconds]); + }, [ + showNextEpisodeCountdown, + autoplayCancelled, + settings.autoplayCountdownSeconds, + ]); const nextEpisodePosterUrl = useMemo( () => diff --git a/hooks/useCastAutoplay.ts b/hooks/useCastAutoplay.ts index 2b9445888..e14c6f230 100644 --- a/hooks/useCastAutoplay.ts +++ b/hooks/useCastAutoplay.ts @@ -17,7 +17,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtom, useAtomValue } from "jotai"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { MediaPlayerIdleReason, MediaPlayerState, @@ -105,6 +105,10 @@ export const useCastAutoplay = (): void => { // from Jellyfin). `mediaInfo` clears at IDLE so we must capture as it plays. const capturedItemRef = useRef(null); const capturedItemIdRef = useRef(null); + // State mirror of the captured item id so downstream effects/hooks re-run + // *after* the async getItem resolves — depending on `contentId` directly + // would fire them before the ref is populated and they'd read stale data. + const [capturedItemId, setCapturedItemId] = useState(null); // Cached next-episode resolution per (seriesId, currentEpisodeId). const nextEpisodeCacheRef = useRef(null); @@ -142,7 +146,14 @@ export const useCastAutoplay = (): void => { // --- 1. Capture the currently-playing item, full BaseItemDto. --- useEffect(() => { - if (!contentId || !api || !user?.Id) return; + if (!contentId || !api || !user?.Id) { + // No active content: clear all captured state so downstream effects / + // useSegments stop using a stale previous-item id. + capturedItemRef.current = null; + capturedItemIdRef.current = null; + setCapturedItemId(null); + return; + } // If the captured id changed, reset the trigger guard immediately — the // user moved to another episode, and that new episode should be eligible. @@ -162,6 +173,10 @@ export const useCastAutoplay = (): void => { if (cancelled) return; capturedItemRef.current = res.data; capturedItemIdRef.current = contentId; + // Publish the captured id as state *after* the ref is set, so the + // next-episode-resolve effect (keyed on this state) sees a populated + // ref by the time it runs. + setCapturedItemId(contentId); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; @@ -223,16 +238,18 @@ export const useCastAutoplay = (): void => { return () => { cancelled = true; }; - // We intentionally depend on contentId (captured item id surrogate) — the - // captured ref updates synchronously in the effect above using the same - // dep, so by the time this runs the ref already points to the new item. - }, [contentId, api, user]); + // Depend on the *state* mirror of the captured id rather than `contentId` + // directly: `contentId` flips synchronously on the new episode, but + // `capturedItemRef.current` is only populated after the async getItem + // resolves. Keying on `capturedItemId` (set right after the ref write) + // guarantees the ref points at the new item by the time we read it here. + }, [capturedItemId, api, user]); // --- 3. Media segments for the captured item (Outro). --- // Matches `useChromecastSegments`: cast playback is online, no downloaded // files context to thread through. const { data: segmentData } = useSegments( - capturedItemIdRef.current ?? "", + capturedItemId ?? "", false, undefined, api, @@ -383,8 +400,11 @@ export const useCastAutoplay = (): void => { return; } + // Read the freshest count at the moment of the write — the + // overlay's "Play now" can reset this to 0 in parallel, and using + // a snapshot taken before the await would clobber that reset. updateSettingsRef.current({ - autoPlayEpisodeCount: settingsLocal.autoPlayEpisodeCount + 1, + autoPlayEpisodeCount: settingsRef.current.autoPlayEpisodeCount + 1, }); toast("Playing next episode"); } catch (error) {