diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 674a6dfd..1b00d095 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -124,6 +124,12 @@ export const Controls: FC = ({ // Ref to track pending play timeout for cleanup and cancellation const playTimeoutRef = useRef | null>(null); + // Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout + const playingRef = useRef(isPlaying); + useEffect(() => { + playingRef.current = isPlaying; + }, [isPlaying]); + // Clean up timeout on unmount useEffect(() => { return () => { @@ -346,8 +352,11 @@ export const Controls: FC = ({ seek(timeInSeconds * 1000); // Brief delay ensures the seek operation completes before resuming playback // Without this, playback may resume from the old position + // Read latest isPlaying from ref to avoid stale closure playTimeoutRef.current = setTimeout(() => { - play(); + if (playingRef.current) { + play(); + } playTimeoutRef.current = null; }, 200); }, @@ -427,7 +436,7 @@ export const Controls: FC = ({ ); const skipIntro = activeSegment?.skipSegment || noop; const showSkipCreditButton = activeSegment?.type === "Outro"; - const skipCredit = outroSkipper.skipSegment; + const skipCredit = outroSkipper.skipSegment || noop; const hasContentAfterCredits = outroSkipper.currentSegment && maxSeconds ? outroSkipper.currentSegment.endTime < maxSeconds diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts deleted file mode 100644 index 40c1d695..00000000 --- a/hooks/useCreditSkipper.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Api } from "@jellyfin/sdk"; -import { useCallback, useEffect, useState } from "react"; -import { DownloadedItem } from "@/providers/Downloads/types"; -import { useSegments } from "@/utils/segments"; -import { msToSeconds, secondsToMs } from "@/utils/time"; -import { useHaptic } from "./useHaptic"; - -/** - * Custom hook to handle skipping credits in a media player. - * The player reports time values in milliseconds. - */ -export const useCreditSkipper = ( - itemId: string, - currentTime: number, - seek: (ms: number) => void, - play: () => void, - isOffline = false, - api: Api | null = null, - downloadedFiles: DownloadedItem[] | undefined = undefined, - totalDuration?: number, -) => { - const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); - const lightHapticFeedback = useHaptic("light"); - - // Convert ms to seconds for comparison with timestamps - const currentTimeSeconds = msToSeconds(currentTime); - - const totalDurationInSeconds = - totalDuration != null ? msToSeconds(totalDuration) : undefined; - - // Regular function (not useCallback) to match useIntroSkipper pattern - const wrappedSeek = (seconds: number) => { - seek(secondsToMs(seconds)); - }; - - const { data: segments } = useSegments( - itemId, - isOffline, - downloadedFiles, - api, - ); - const creditTimestamps = segments?.creditSegments?.[0]; - - // Determine if there's content after credits (credits don't extend to video end) - // Use a 5-second buffer to account for timing discrepancies - const hasContentAfterCredits = (() => { - if ( - !creditTimestamps || - totalDurationInSeconds == null || - !Number.isFinite(totalDurationInSeconds) - ) { - return false; - } - const creditsEndToVideoEnd = - totalDurationInSeconds - creditTimestamps.endTime; - // If credits end more than 5 seconds before video ends, there's content after - return creditsEndToVideoEnd > 5; - })(); - - useEffect(() => { - if (creditTimestamps) { - const shouldShow = - currentTimeSeconds > creditTimestamps.startTime && - currentTimeSeconds < creditTimestamps.endTime; - - setShowSkipCreditButton(shouldShow); - } else { - // Reset button state when no credit timestamps exist - if (showSkipCreditButton) { - setShowSkipCreditButton(false); - } - } - }, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]); - - const skipCredit = useCallback(() => { - if (!creditTimestamps) return; - - try { - lightHapticFeedback(); - - // Calculate the target seek position - let seekTarget = creditTimestamps.endTime; - - // If we have total duration, ensure we don't seek past the end of the video. - // Some media sources report credit end times that exceed the actual video duration, - // which causes the player to pause/stop when seeking past the end. - // Leave a small buffer (2 seconds) to trigger the natural end-of-video flow - // (next episode countdown, etc.) instead of an abrupt pause. - if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) { - seekTarget = Math.max(0, totalDurationInSeconds - 2); - } - - wrappedSeek(seekTarget); - setTimeout(() => { - play(); - }, 200); - } catch (error) { - console.error("[CREDIT_SKIPPER] Error skipping credit", error); - } - }, [ - creditTimestamps, - lightHapticFeedback, - wrappedSeek, - play, - totalDurationInSeconds, - ]); - - return { showSkipCreditButton, skipCredit, hasContentAfterCredits }; -}; diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts deleted file mode 100644 index eeed9833..00000000 --- a/hooks/useIntroSkipper.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Api } from "@jellyfin/sdk"; -import { useCallback, useEffect, useState } from "react"; -import { DownloadedItem } from "@/providers/Downloads/types"; -import { useSegments } from "@/utils/segments"; -import { msToSeconds, secondsToMs } from "@/utils/time"; -import { useHaptic } from "./useHaptic"; - -/** - * Custom hook to handle skipping intros in a media player. - * MPV player uses milliseconds for time. - * - * @param {number} currentTime - The current playback time in milliseconds. - */ -export const useIntroSkipper = ( - itemId: string, - currentTime: number, - seek: (ms: number) => void, - play: () => void, - isOffline = false, - api: Api | null = null, - downloadedFiles: DownloadedItem[] | undefined = undefined, -) => { - const [showSkipButton, setShowSkipButton] = useState(false); - // Convert ms to seconds for comparison with timestamps - const currentTimeSeconds = msToSeconds(currentTime); - const lightHapticFeedback = useHaptic("light"); - - const wrappedSeek = (seconds: number) => { - seek(secondsToMs(seconds)); - }; - - const { data: segments } = useSegments( - itemId, - isOffline, - downloadedFiles, - api, - ); - const introTimestamps = segments?.introSegments?.[0]; - - useEffect(() => { - if (introTimestamps) { - const shouldShow = - currentTimeSeconds > introTimestamps.startTime && - currentTimeSeconds < introTimestamps.endTime; - - setShowSkipButton(shouldShow); - } else { - if (showSkipButton) { - setShowSkipButton(false); - } - } - }, [introTimestamps, currentTimeSeconds, showSkipButton]); - - const skipIntro = useCallback(() => { - if (!introTimestamps) return; - try { - lightHapticFeedback(); - wrappedSeek(introTimestamps.endTime); - setTimeout(() => { - play(); - }, 200); - } catch (error) { - console.error("[INTRO_SKIPPER] Error skipping intro", error); - } - }, [introTimestamps, lightHapticFeedback, wrappedSeek, play]); - - return { showSkipButton, skipIntro }; -}; diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts index a3b54a5c..1b44c13e 100644 --- a/hooks/useSegmentSkipper.ts +++ b/hooks/useSegmentSkipper.ts @@ -33,7 +33,7 @@ export const useSegmentSkipper = ({ }: UseSegmentSkipperProps): UseSegmentSkipperReturn => { const { settings } = useSettings(); const haptic = useHaptic(); - const autoSkipTriggeredRef = useRef(false); + const autoSkipTriggeredRef = useRef(null); // Get skip mode based on segment type const skipMode = (() => { @@ -63,10 +63,14 @@ export const useSegmentSkipper = ({ // Skip function with optional haptic feedback const skipSegment = useCallback( (notifyOrUseHaptics = true) => { - if (!currentSegment) return; + 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 { @@ -78,22 +82,26 @@ export const useSegmentSkipper = ({ haptic(); } }, - [currentSegment, segmentType, totalDuration, seek, haptic], + [currentSegment, segmentType, totalDuration, seek, haptic, skipMode], ); // Auto-skip logic when mode is 'auto' useEffect(() => { if (skipMode !== "auto" || isPaused) { - autoSkipTriggeredRef.current = false; return; } - if (currentSegment && !autoSkipTriggeredRef.current) { - autoSkipTriggeredRef.current = true; + // Track segment identity to avoid re-triggering on pause/unpause + const segmentId = currentSegment + ? `${currentSegment.startTime}-${currentSegment.endTime}` + : null; + + if (currentSegment && autoSkipTriggeredRef.current !== segmentId) { + autoSkipTriggeredRef.current = segmentId; skipSegment(false); // Don't trigger haptics for auto-skip } if (!currentSegment) { - autoSkipTriggeredRef.current = false; + autoSkipTriggeredRef.current = null; } }, [currentSegment, skipMode, isPaused, skipSegment]); diff --git a/utils/segments.ts b/utils/segments.ts index 9b2cd856..d3bcd6a0 100644 --- a/utils/segments.ts +++ b/utils/segments.ts @@ -185,38 +185,31 @@ const fetchLegacySegments = async ( const introSegments: MediaTimeSegment[] = []; const creditSegments: MediaTimeSegment[] = []; - try { - const [introRes, creditRes] = await Promise.allSettled([ - api.axiosInstance.get( - `${api.basePath}/Episode/${itemId}/IntroTimestamps`, - { headers: getAuthHeaders(api) }, - ), - api.axiosInstance.get( - `${api.basePath}/Episode/${itemId}/Timestamps`, - { headers: getAuthHeaders(api) }, - ), - ]); + const [introRes, creditRes] = await Promise.allSettled([ + api.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/IntroTimestamps`, + { headers: getAuthHeaders(api) }, + ), + api.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/Timestamps`, + { headers: getAuthHeaders(api) }, + ), + ]); - if (introRes.status === "fulfilled" && introRes.value.data.Valid) { - introSegments.push({ - startTime: introRes.value.data.IntroStart, - endTime: introRes.value.data.IntroEnd, - text: "Intro", - }); - } + if (introRes.status === "fulfilled" && introRes.value.data.Valid) { + introSegments.push({ + startTime: introRes.value.data.IntroStart, + endTime: introRes.value.data.IntroEnd, + text: "Intro", + }); + } - if ( - creditRes.status === "fulfilled" && - creditRes.value.data.Credits.Valid - ) { - creditSegments.push({ - startTime: creditRes.value.data.Credits.Start, - endTime: creditRes.value.data.Credits.End, - text: "Credits", - }); - } - } catch (error) { - console.error("Failed to fetch legacy segments", error); + if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) { + creditSegments.push({ + startTime: creditRes.value.data.Credits.Start, + endTime: creditRes.value.data.Credits.End, + text: "Credits", + }); } return {