From d1795c9df8d25cc095baf4b846b1c66fe42054f3 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:09:57 +0200 Subject: [PATCH] fix(player): Fix skip credits seeking past video end causing pause (#1277) --- .../video-player/controls/BottomControls.tsx | 15 ++-- components/video-player/controls/Controls.tsx | 23 +++--- hooks/useCreditSkipper.ts | 71 ++++++++++++++++--- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index a2652d70..7519b1ed 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -21,6 +21,7 @@ interface BottomControlsProps { isVlc: boolean; showSkipButton: boolean; showSkipCreditButton: boolean; + hasContentAfterCredits: boolean; skipIntro: () => void; skipCredit: () => void; nextItem?: BaseItemDto | null; @@ -69,6 +70,7 @@ export const BottomControls: FC = ({ isVlc, showSkipButton, showSkipCreditButton, + hasContentAfterCredits, skipIntro, skipCredit, nextItem, @@ -136,8 +138,13 @@ export const BottomControls: FC = ({ onPress={skipIntro} buttonText='Skip Intro' /> + {/* Smart Skip Credits behavior: + - Show "Skip Credits" if there's content after credits OR no next episode + - Show "Next Episode" if credits extend to video end AND next episode exists */} @@ -148,9 +155,9 @@ export const BottomControls: FC = ({ show={ !nextItem ? false - : isVlc - ? remainingTime < 10000 - : remainingTime < 10 + : // Show during credits if no content after, OR near end of video + (showSkipCreditButton && !hasContentAfterCredits) || + (isVlc ? remainingTime < 10000 : remainingTime < 10) } onFinish={handleNextEpisodeAutoPlay} onPress={handleNextEpisodeManual} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index fb62fcef..53837482 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -331,16 +331,18 @@ export const Controls: FC = ({ downloadedFiles, ); - const { showSkipCreditButton, skipCredit } = useCreditSkipper( - item.Id!, - currentTime, - seek, - play, - isVlc, - offline, - api, - downloadedFiles, - ); + const { showSkipCreditButton, skipCredit, hasContentAfterCredits } = + useCreditSkipper( + item.Id!, + currentTime, + seek, + play, + isVlc, + offline, + api, + downloadedFiles, + max.value, + ); const goToItemCommon = useCallback( (item: BaseItemDto) => { @@ -557,6 +559,7 @@ export const Controls: FC = ({ isVlc={isVlc} showSkipButton={showSkipButton} showSkipCreditButton={showSkipCreditButton} + hasContentAfterCredits={hasContentAfterCredits} skipIntro={skipIntro} skipCredit={skipCredit} nextItem={nextItem} diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts index d023e7be..91db8c29 100644 --- a/hooks/useCreditSkipper.ts +++ b/hooks/useCreditSkipper.ts @@ -5,6 +5,19 @@ 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. + * + * @param {string} itemId - The ID of the media item. + * @param {number} currentTime - The current playback time (ms for VLC, seconds otherwise). + * @param {function} seek - Function to seek to a position. + * @param {function} play - Function to resume playback. + * @param {boolean} isVlc - Whether using VLC player (uses milliseconds). + * @param {boolean} isOffline - Whether in offline mode. + * @param {Api|null} api - The Jellyfin API client. + * @param {DownloadedItem[]|undefined} downloadedFiles - Downloaded files for offline mode. + * @param {number|undefined} totalDuration - Total duration of the video (ms for VLC, seconds otherwise). + */ export const useCreditSkipper = ( itemId: string, currentTime: number, @@ -14,14 +27,20 @@ export const useCreditSkipper = ( isOffline = false, api: Api | null = null, downloadedFiles: DownloadedItem[] | undefined = undefined, + totalDuration?: number, ) => { const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); const lightHapticFeedback = useHaptic("light"); + // Convert currentTime to seconds for consistent comparison (matching useIntroSkipper pattern) if (isVlc) { currentTime = msToSeconds(currentTime); } + const totalDurationInSeconds = + isVlc && totalDuration ? msToSeconds(totalDuration) : totalDuration; + + // Regular function (not useCallback) to match useIntroSkipper pattern const wrappedSeek = (seconds: number) => { if (isVlc) { seek(secondsToMs(seconds)); @@ -38,27 +57,63 @@ export const useCreditSkipper = ( ); 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) 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) { - setShowSkipCreditButton( + const shouldShow = currentTime > creditTimestamps.startTime && - currentTime < creditTimestamps.endTime, - ); + currentTime < creditTimestamps.endTime; + + setShowSkipCreditButton(shouldShow); + } else { + // Reset button state when no credit timestamps exist + if (showSkipCreditButton) { + setShowSkipCreditButton(false); + } } - }, [creditTimestamps, currentTime]); + }, [creditTimestamps, currentTime, showSkipCreditButton]); const skipCredit = useCallback(() => { if (!creditTimestamps) return; + try { lightHapticFeedback(); - wrappedSeek(creditTimestamps.endTime); + + // 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("Error skipping credit", error); + console.error("[CREDIT_SKIPPER] Error skipping credit", error); } - }, [creditTimestamps, lightHapticFeedback, wrappedSeek, play]); + }, [ + creditTimestamps, + lightHapticFeedback, + wrappedSeek, + play, + totalDurationInSeconds, + ]); - return { showSkipCreditButton, skipCredit }; + return { showSkipCreditButton, skipCredit, hasContentAfterCredits }; };