mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
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
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:
committed by
GitHub
parent
149609f46e
commit
d1795c9df8
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user