fix(autoplay): make Cancel stop the timer and fix stale cast capture state

This commit is contained in:
Uruk
2026-05-23 23:36:38 +02:00
parent 56e350891d
commit 2c2a7137d3
3 changed files with 48 additions and 13 deletions

View File

@@ -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]);

View File

@@ -147,14 +147,22 @@ export const BottomControls: FC<BottomControlsProps> = ({
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<BottomControlsProps> = ({
intervalRef.current = null;
}
};
}, [showNextEpisodeCountdown, settings.autoplayCountdownSeconds]);
}, [
showNextEpisodeCountdown,
autoplayCancelled,
settings.autoplayCountdownSeconds,
]);
const nextEpisodePosterUrl = useMemo(
() =>

View File

@@ -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<BaseItemDto | null>(null);
const capturedItemIdRef = useRef<string | null>(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<string | null>(null);
// Cached next-episode resolution per (seriesId, currentEpisodeId).
const nextEpisodeCacheRef = useRef<NextEpisodeCache | null>(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) {