mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
110 lines
3.4 KiB
TypeScript
110 lines
3.4 KiB
TypeScript
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 };
|
|
};
|