fix(player): Fix skip credits seeking past video end causing pause (#1277)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled

This commit is contained in:
Cristea Florian Victor
2025-12-21 11:09:57 +02:00
committed by GitHub
parent 149609f46e
commit d1795c9df8
3 changed files with 87 additions and 22 deletions

View File

@@ -21,6 +21,7 @@ interface BottomControlsProps {
isVlc: boolean; isVlc: boolean;
showSkipButton: boolean; showSkipButton: boolean;
showSkipCreditButton: boolean; showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
skipIntro: () => void; skipIntro: () => void;
skipCredit: () => void; skipCredit: () => void;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
@@ -69,6 +70,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
isVlc, isVlc,
showSkipButton, showSkipButton,
showSkipCreditButton, showSkipCreditButton,
hasContentAfterCredits,
skipIntro, skipIntro,
skipCredit, skipCredit,
nextItem, nextItem,
@@ -136,8 +138,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={skipIntro} onPress={skipIntro}
buttonText='Skip Intro' 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 */}
<SkipButton <SkipButton
showButton={showSkipCreditButton} showButton={
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit} onPress={skipCredit}
buttonText='Skip Credits' buttonText='Skip Credits'
/> />
@@ -148,9 +155,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
show={ show={
!nextItem !nextItem
? false ? false
: isVlc : // Show during credits if no content after, OR near end of video
? remainingTime < 10000 (showSkipCreditButton && !hasContentAfterCredits) ||
: remainingTime < 10 (isVlc ? remainingTime < 10000 : remainingTime < 10)
} }
onFinish={handleNextEpisodeAutoPlay} onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual} onPress={handleNextEpisodeManual}

View File

@@ -331,16 +331,18 @@ export const Controls: FC<Props> = ({
downloadedFiles, downloadedFiles,
); );
const { showSkipCreditButton, skipCredit } = useCreditSkipper( const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
item.Id!, useCreditSkipper(
currentTime, item.Id!,
seek, currentTime,
play, seek,
isVlc, play,
offline, isVlc,
api, offline,
downloadedFiles, api,
); downloadedFiles,
max.value,
);
const goToItemCommon = useCallback( const goToItemCommon = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
@@ -557,6 +559,7 @@ export const Controls: FC<Props> = ({
isVlc={isVlc} isVlc={isVlc}
showSkipButton={showSkipButton} showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton} showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro} skipIntro={skipIntro}
skipCredit={skipCredit} skipCredit={skipCredit}
nextItem={nextItem} nextItem={nextItem}

View File

@@ -5,6 +5,19 @@ import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time"; import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic"; 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 = ( export const useCreditSkipper = (
itemId: string, itemId: string,
currentTime: number, currentTime: number,
@@ -14,14 +27,20 @@ export const useCreditSkipper = (
isOffline = false, isOffline = false,
api: Api | null = null, api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined, downloadedFiles: DownloadedItem[] | undefined = undefined,
totalDuration?: number,
) => { ) => {
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
// Convert currentTime to seconds for consistent comparison (matching useIntroSkipper pattern)
if (isVlc) { if (isVlc) {
currentTime = msToSeconds(currentTime); currentTime = msToSeconds(currentTime);
} }
const totalDurationInSeconds =
isVlc && totalDuration ? msToSeconds(totalDuration) : totalDuration;
// Regular function (not useCallback) to match useIntroSkipper pattern
const wrappedSeek = (seconds: number) => { const wrappedSeek = (seconds: number) => {
if (isVlc) { if (isVlc) {
seek(secondsToMs(seconds)); seek(secondsToMs(seconds));
@@ -38,27 +57,63 @@ export const useCreditSkipper = (
); );
const creditTimestamps = segments?.creditSegments?.[0]; 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(() => { useEffect(() => {
if (creditTimestamps) { if (creditTimestamps) {
setShowSkipCreditButton( const shouldShow =
currentTime > creditTimestamps.startTime && 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(() => { const skipCredit = useCallback(() => {
if (!creditTimestamps) return; if (!creditTimestamps) return;
try { try {
lightHapticFeedback(); 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(() => { setTimeout(() => {
play(); play();
}, 200); }, 200);
} catch (error) { } 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 };
}; };