mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-19 12:20:26 +01:00
fix(player): unify media-segment skip across mobile and TV
Replace the duplicated per-platform segment-skip logic with a shared useMediaSegments hook: per-type skippers, overlap priority (Commercial > Recap > Intro > Preview > Outro) and a single auto-skip driver so both platforms behave identically. - One auto-skip effect on the priority-resolved active segment, so overlapping auto segments can't fire competing seeks. - Sub-second precision (stop flooring currentTime to whole seconds). - Gate auto-skip on !isBuffering plus a short arm delay so it never seeks a not-yet-seekable transcoded stream at a 0:00 intro. - Dedup guard survives the transient null when a transcoded stream bounces the reported position, instead of looping seeks.
This commit is contained in:
@@ -4,14 +4,7 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
@@ -25,8 +18,8 @@ import Animated, {
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useMediaSegments } from "@/hooks/useMediaSegments";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
@@ -34,7 +27,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
@@ -51,9 +44,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
// No-op function to avoid creating new references on every render
|
||||
const noop = () => {};
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
isPlaying: boolean;
|
||||
@@ -122,24 +112,6 @@ export const Controls: FC<Props> = ({
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
// Ref to track pending play timeout for cleanup and cancellation
|
||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
@@ -353,127 +325,25 @@ export const Controls: FC<Props> = ({
|
||||
api,
|
||||
);
|
||||
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
||||
|
||||
// Segment hook deals in seconds; player API in ms. The 200ms delayed play()
|
||||
// is a workaround: some seeks otherwise resume from the pre-seek position.
|
||||
const seekMs = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
seek(timeInSeconds * 1000);
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
// playingRef avoids a stale closure: re-check current isPlaying.
|
||||
if (playingRef.current) {
|
||||
play();
|
||||
}
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, play],
|
||||
);
|
||||
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments || [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
// Unified segment orchestration (identical mechanism on mobile and TV):
|
||||
// overlap priority + a single auto-skip driver live in the shared hook.
|
||||
const {
|
||||
activeSegment,
|
||||
skipActiveSegment: onSkipSegment,
|
||||
showSkipButton: showSkipSegmentButton,
|
||||
isOutroActive: showSkipOutroButton,
|
||||
skipOutro: onSkipOutro,
|
||||
hasContentAfterCredits,
|
||||
} = useMediaSegments({
|
||||
segments,
|
||||
currentTime,
|
||||
maxMs,
|
||||
seek,
|
||||
play,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
});
|
||||
|
||||
const outroSkipper = useSegmentSkipper({
|
||||
segments: segments?.creditSegments || [],
|
||||
segmentType: "Outro",
|
||||
currentTime: currentTimeSeconds,
|
||||
totalDuration: maxSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const recapSkipper = useSegmentSkipper({
|
||||
segments: segments?.recapSegments || [],
|
||||
segmentType: "Recap",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const commercialSkipper = useSegmentSkipper({
|
||||
segments: segments?.commercialSegments || [],
|
||||
segmentType: "Commercial",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const previewSkipper = useSegmentSkipper({
|
||||
segments: segments?.previewSegments || [],
|
||||
segmentType: "Preview",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
// Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro.
|
||||
const activeSegment = useMemo(() => {
|
||||
if (commercialSkipper.currentSegment)
|
||||
return {
|
||||
type: "Commercial" as const,
|
||||
currentSegment: commercialSkipper.currentSegment,
|
||||
skipSegment: commercialSkipper.skipSegment,
|
||||
};
|
||||
if (recapSkipper.currentSegment)
|
||||
return {
|
||||
type: "Recap" as const,
|
||||
currentSegment: recapSkipper.currentSegment,
|
||||
skipSegment: recapSkipper.skipSegment,
|
||||
};
|
||||
if (introSkipper.currentSegment)
|
||||
return {
|
||||
type: "Intro" as const,
|
||||
currentSegment: introSkipper.currentSegment,
|
||||
skipSegment: introSkipper.skipSegment,
|
||||
};
|
||||
if (previewSkipper.currentSegment)
|
||||
return {
|
||||
type: "Preview" as const,
|
||||
currentSegment: previewSkipper.currentSegment,
|
||||
skipSegment: previewSkipper.skipSegment,
|
||||
};
|
||||
if (outroSkipper.currentSegment)
|
||||
return {
|
||||
type: "Outro" as const,
|
||||
currentSegment: outroSkipper.currentSegment,
|
||||
skipSegment: outroSkipper.skipSegment,
|
||||
};
|
||||
return null;
|
||||
}, [
|
||||
commercialSkipper.currentSegment,
|
||||
commercialSkipper.skipSegment,
|
||||
recapSkipper.currentSegment,
|
||||
recapSkipper.skipSegment,
|
||||
introSkipper.currentSegment,
|
||||
introSkipper.skipSegment,
|
||||
previewSkipper.currentSegment,
|
||||
previewSkipper.skipSegment,
|
||||
outroSkipper.currentSegment,
|
||||
outroSkipper.skipSegment,
|
||||
]);
|
||||
|
||||
// Outro gets a dedicated button (so it can compose with Next Episode logic);
|
||||
// every other segment type shares the generic skip button.
|
||||
const showSkipSegmentButton =
|
||||
!!activeSegment && activeSegment.type !== "Outro";
|
||||
const onSkipSegment = activeSegment?.skipSegment ?? noop;
|
||||
const showSkipOutroButton = activeSegment?.type === "Outro";
|
||||
const onSkipOutro = outroSkipper.skipSegment;
|
||||
const hasContentAfterCredits =
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
: false;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const skipSegmentButtonText = activeSegment
|
||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
||||
|
||||
@@ -38,8 +38,9 @@ import {
|
||||
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useMediaSegments } from "@/hooks/useMediaSegments";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||
import type { SegmentType } from "@/hooks/useSegmentSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||
@@ -51,13 +52,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import {
|
||||
formatTimeString,
|
||||
msToSeconds,
|
||||
msToTicks,
|
||||
secondsToMs,
|
||||
ticksToMs,
|
||||
} from "@/utils/time";
|
||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { useVideoContext } from "./contexts/VideoContext";
|
||||
import { useChapterNavigation } from "./hooks/useChapterNavigation";
|
||||
@@ -105,9 +100,6 @@ interface Props {
|
||||
const TV_SEEKBAR_HEIGHT = 14;
|
||||
const TV_AUTO_HIDE_TIMEOUT = 5000;
|
||||
|
||||
// Stable no-op so the generic skip card keeps a constant onPress when idle.
|
||||
const noop = () => {};
|
||||
|
||||
// Trickplay bubble positioning constants
|
||||
const TV_TRICKPLAY_SCALE = 2;
|
||||
const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
|
||||
@@ -208,6 +200,7 @@ export const Controls: FC<Props> = ({
|
||||
isSeeking,
|
||||
progress,
|
||||
cacheProgress,
|
||||
isBuffering,
|
||||
showControls,
|
||||
setShowControls,
|
||||
mediaSource,
|
||||
@@ -446,129 +439,32 @@ export const Controls: FC<Props> = ({
|
||||
api,
|
||||
);
|
||||
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const maxSeconds = msToSeconds(maxMs);
|
||||
|
||||
// useSegmentSkipper deals in seconds; the player seek expects ms. The 200ms
|
||||
// delayed play() mirrors the mobile controls: some seeks otherwise resume
|
||||
// from the pre-seek position.
|
||||
const playSegmentTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (playSegmentTimeoutRef.current) {
|
||||
clearTimeout(playSegmentTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const seekSeconds = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
if (playSegmentTimeoutRef.current) {
|
||||
clearTimeout(playSegmentTimeoutRef.current);
|
||||
}
|
||||
seek(secondsToMs(timeInSeconds));
|
||||
playSegmentTimeoutRef.current = setTimeout(() => {
|
||||
_play();
|
||||
playSegmentTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, _play],
|
||||
);
|
||||
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments ?? [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekSeconds,
|
||||
isPaused: !isPlaying,
|
||||
// Unified segment orchestration (identical mechanism on mobile and TV):
|
||||
// overlap priority + a single auto-skip driver live in the shared hook.
|
||||
const {
|
||||
activeSegment,
|
||||
skipActiveSegment,
|
||||
showSkipButton,
|
||||
isOutroActive,
|
||||
skipOutro: skipCredit,
|
||||
hasContentAfterCredits,
|
||||
} = useMediaSegments({
|
||||
segments,
|
||||
currentTime,
|
||||
maxMs,
|
||||
seek,
|
||||
play: _play,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
});
|
||||
|
||||
const outroSkipper = useSegmentSkipper({
|
||||
segments: segments?.creditSegments ?? [],
|
||||
segmentType: "Outro",
|
||||
currentTime: currentTimeSeconds,
|
||||
totalDuration: maxSeconds,
|
||||
seek: seekSeconds,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const recapSkipper = useSegmentSkipper({
|
||||
segments: segments?.recapSegments ?? [],
|
||||
segmentType: "Recap",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekSeconds,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const commercialSkipper = useSegmentSkipper({
|
||||
segments: segments?.commercialSegments ?? [],
|
||||
segmentType: "Commercial",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekSeconds,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const previewSkipper = useSegmentSkipper({
|
||||
segments: segments?.previewSegments ?? [],
|
||||
segmentType: "Preview",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekSeconds,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
// Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro.
|
||||
// The outro keeps its dedicated card (it composes with the Next Episode
|
||||
// countdown); the other four share one generic skip card. Including the outro
|
||||
// here keeps the two cards mutually exclusive.
|
||||
const activeSegment = useMemo(() => {
|
||||
if (commercialSkipper.currentSegment)
|
||||
return {
|
||||
type: "commercial" as const,
|
||||
skipSegment: commercialSkipper.skipSegment,
|
||||
};
|
||||
if (recapSkipper.currentSegment)
|
||||
return { type: "recap" as const, skipSegment: recapSkipper.skipSegment };
|
||||
if (introSkipper.currentSegment)
|
||||
return { type: "intro" as const, skipSegment: introSkipper.skipSegment };
|
||||
if (previewSkipper.currentSegment)
|
||||
return {
|
||||
type: "preview" as const,
|
||||
skipSegment: previewSkipper.skipSegment,
|
||||
};
|
||||
if (outroSkipper.currentSegment)
|
||||
return { type: "outro" as const, skipSegment: outroSkipper.skipSegment };
|
||||
return null;
|
||||
}, [
|
||||
commercialSkipper.currentSegment,
|
||||
commercialSkipper.skipSegment,
|
||||
recapSkipper.currentSegment,
|
||||
recapSkipper.skipSegment,
|
||||
introSkipper.currentSegment,
|
||||
introSkipper.skipSegment,
|
||||
previewSkipper.currentSegment,
|
||||
previewSkipper.skipSegment,
|
||||
outroSkipper.currentSegment,
|
||||
outroSkipper.skipSegment,
|
||||
]);
|
||||
|
||||
const isOutroActive = activeSegment?.type === "outro";
|
||||
|
||||
// Generic card (intro/recap/commercial/preview).
|
||||
const showSkipButton = !!activeSegment && !isOutroActive;
|
||||
const skipActiveSegment = activeSegment?.skipSegment ?? noop;
|
||||
const activeSegmentType = isOutroActive
|
||||
? "intro"
|
||||
: (activeSegment?.type ?? "intro");
|
||||
|
||||
// Outro card (composes with the Next Episode countdown).
|
||||
// countdown); the other four share the generic skip card.
|
||||
const showSkipCreditButton = isOutroActive;
|
||||
const skipCredit = outroSkipper.skipSegment;
|
||||
const hasContentAfterCredits =
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
: false;
|
||||
const activeSegmentType =
|
||||
isOutroActive || !activeSegment
|
||||
? "intro"
|
||||
: (activeSegment.type.toLowerCase() as Lowercase<SegmentType>);
|
||||
|
||||
// Countdown logic
|
||||
const isCountdownActive = useMemo(() => {
|
||||
|
||||
220
hooks/useMediaSegments.ts
Normal file
220
hooks/useMediaSegments.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import type { SegmentSkipMode } from "@/utils/atoms/settings";
|
||||
import type { SegmentBuckets } from "@/utils/segments";
|
||||
import { type SegmentType, useSegmentSkipper } from "./useSegmentSkipper";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// Delay the FIRST auto-skip until playback has been stable this long. Seeking a
|
||||
// transcoded stream the instant the first frame appears (e.g. a 0:00 intro)
|
||||
// asks the transcode for a segment it hasn't produced yet and stalls at 0:00;
|
||||
// direct-play is always seekable so the delay is invisible there.
|
||||
const AUTO_SKIP_ARM_DELAY_MS = 1500;
|
||||
|
||||
export interface ActiveSegment {
|
||||
type: SegmentType;
|
||||
currentSegment: MediaTimeSegment;
|
||||
skipSegment: (useHaptics?: boolean) => void;
|
||||
skipMode: SegmentSkipMode;
|
||||
}
|
||||
|
||||
interface UseMediaSegmentsProps {
|
||||
segments: SegmentBuckets | undefined;
|
||||
/** Current playback position, in ms. */
|
||||
currentTime: number;
|
||||
/** Total media duration, in ms. */
|
||||
maxMs?: number;
|
||||
/** Player seek, expects ms. */
|
||||
seek: (ms: number) => void;
|
||||
/** Player resume. */
|
||||
play: () => void;
|
||||
isPlaying: boolean;
|
||||
/** True while the player is (re)buffering; auto-skip waits for this to clear. */
|
||||
isBuffering?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMediaSegmentsReturn {
|
||||
/** Highest-priority segment under the playhead (excludes 'none' types), or null. */
|
||||
activeSegment: ActiveSegment | null;
|
||||
/** Skip the active segment (no-op when there is none). */
|
||||
skipActiveSegment: (useHaptics?: boolean) => void;
|
||||
/** Show the generic skip button: an active segment that is not the outro. */
|
||||
showSkipButton: boolean;
|
||||
/** The active segment is the outro/credits (it gets its own button/card). */
|
||||
isOutroActive: boolean;
|
||||
/** Skip the outro, independent of which button the priority shows. */
|
||||
skipOutro: (useHaptics?: boolean) => void;
|
||||
/** The outro ends before the media end, i.e. there is content after credits. */
|
||||
hasContentAfterCredits: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified media-segment orchestration shared by the mobile and TV player controls.
|
||||
* Owns the per-type skippers, the seek-with-delayed-play workaround, the overlap
|
||||
* priority (Commercial > Recap > Intro > Preview > Outro) and a SINGLE auto-skip
|
||||
* driver, so overlapping auto-enabled segments can't fire competing seeks and both
|
||||
* platforms behave identically.
|
||||
*/
|
||||
export const useMediaSegments = ({
|
||||
segments,
|
||||
currentTime,
|
||||
maxMs,
|
||||
seek,
|
||||
play,
|
||||
isPlaying,
|
||||
isBuffering = false,
|
||||
}: UseMediaSegmentsProps): UseMediaSegmentsReturn => {
|
||||
// Keep sub-second precision: segment boundaries are fractional seconds, so
|
||||
// flooring currentTime would detect segments up to ~1s late / end them early.
|
||||
const currentTimeSeconds = currentTime / 1000;
|
||||
const maxSeconds = maxMs ? maxMs / 1000 : undefined;
|
||||
|
||||
// Seek-with-delayed-play workaround: some seeks otherwise resume from the
|
||||
// pre-seek position. playingRef avoids a stale closure on isPlaying.
|
||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const playingRef = useRef(isPlaying);
|
||||
useEffect(() => {
|
||||
playingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (playTimeoutRef.current) clearTimeout(playTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const seekSeconds = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
if (playTimeoutRef.current) clearTimeout(playTimeoutRef.current);
|
||||
seek(timeInSeconds * 1000);
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
if (playingRef.current) play();
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, play],
|
||||
);
|
||||
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments ?? [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekSeconds,
|
||||
});
|
||||
const outroSkipper = useSegmentSkipper({
|
||||
segments: segments?.creditSegments ?? [],
|
||||
segmentType: "Outro",
|
||||
currentTime: currentTimeSeconds,
|
||||
totalDuration: maxSeconds,
|
||||
seek: seekSeconds,
|
||||
});
|
||||
const recapSkipper = useSegmentSkipper({
|
||||
segments: segments?.recapSegments ?? [],
|
||||
segmentType: "Recap",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekSeconds,
|
||||
});
|
||||
const commercialSkipper = useSegmentSkipper({
|
||||
segments: segments?.commercialSegments ?? [],
|
||||
segmentType: "Commercial",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekSeconds,
|
||||
});
|
||||
const previewSkipper = useSegmentSkipper({
|
||||
segments: segments?.previewSegments ?? [],
|
||||
segmentType: "Preview",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekSeconds,
|
||||
});
|
||||
|
||||
// Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro.
|
||||
const activeSegment = useMemo<ActiveSegment | null>(() => {
|
||||
const byPriority: Array<[SegmentType, typeof introSkipper]> = [
|
||||
["Commercial", commercialSkipper],
|
||||
["Recap", recapSkipper],
|
||||
["Intro", introSkipper],
|
||||
["Preview", previewSkipper],
|
||||
["Outro", outroSkipper],
|
||||
];
|
||||
for (const [type, skipper] of byPriority) {
|
||||
if (skipper.currentSegment) {
|
||||
return {
|
||||
type,
|
||||
currentSegment: skipper.currentSegment,
|
||||
skipSegment: skipper.skipSegment,
|
||||
skipMode: skipper.skipMode,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
commercialSkipper.currentSegment,
|
||||
commercialSkipper.skipSegment,
|
||||
commercialSkipper.skipMode,
|
||||
recapSkipper.currentSegment,
|
||||
recapSkipper.skipSegment,
|
||||
recapSkipper.skipMode,
|
||||
introSkipper.currentSegment,
|
||||
introSkipper.skipSegment,
|
||||
introSkipper.skipMode,
|
||||
previewSkipper.currentSegment,
|
||||
previewSkipper.skipSegment,
|
||||
previewSkipper.skipMode,
|
||||
outroSkipper.currentSegment,
|
||||
outroSkipper.skipSegment,
|
||||
outroSkipper.skipMode,
|
||||
]);
|
||||
|
||||
// Single auto-skip driver: only the priority-resolved active segment skips,
|
||||
// so overlapping auto-enabled segments can't trigger competing seeks.
|
||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
||||
const [autoSkipArmed, setAutoSkipArmed] = useState(false);
|
||||
|
||||
// Reset per item (its segments change): re-allow skipping and re-arm so the
|
||||
// next episode's transcode has time to become seekable. We do NOT reset the
|
||||
// guard when the active segment momentarily disappears — seeking a transcoded
|
||||
// stream makes the reported position bounce back into a 0:00 intro, and
|
||||
// clearing the guard there caused an infinite seek loop that crashed mpv.
|
||||
useEffect(() => {
|
||||
autoSkipTriggeredRef.current = null;
|
||||
setAutoSkipArmed(false);
|
||||
}, [segments]);
|
||||
|
||||
// Arm auto-skip once playback has been genuinely stable (not buffering) for a
|
||||
// short moment, so the first seek lands on an established (seekable) timeline.
|
||||
useEffect(() => {
|
||||
if (autoSkipArmed || isBuffering || !isPlaying) return;
|
||||
const id = setTimeout(() => setAutoSkipArmed(true), AUTO_SKIP_ARM_DELAY_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [autoSkipArmed, isBuffering, isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!autoSkipArmed ||
|
||||
!activeSegment ||
|
||||
!isPlaying ||
|
||||
isBuffering ||
|
||||
activeSegment.skipMode !== "auto"
|
||||
)
|
||||
return;
|
||||
const { startTime, endTime } = activeSegment.currentSegment;
|
||||
const segmentId = `${activeSegment.type}:${startTime}-${endTime}`;
|
||||
if (autoSkipTriggeredRef.current === segmentId) return;
|
||||
autoSkipTriggeredRef.current = segmentId;
|
||||
activeSegment.skipSegment(false);
|
||||
}, [activeSegment, isPlaying, isBuffering, autoSkipArmed]);
|
||||
|
||||
const isOutroActive = activeSegment?.type === "Outro";
|
||||
|
||||
return {
|
||||
activeSegment,
|
||||
skipActiveSegment: activeSegment?.skipSegment ?? noop,
|
||||
showSkipButton: !!activeSegment && !isOutroActive,
|
||||
isOutroActive,
|
||||
skipOutro: outroSkipper.skipSegment,
|
||||
hasContentAfterCredits:
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
: false,
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,12 @@ import { MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
export type SegmentType =
|
||||
| "Intro"
|
||||
| "Outro"
|
||||
| "Recap"
|
||||
| "Commercial"
|
||||
| "Preview";
|
||||
|
||||
const SEGMENT_TO_SETTING: Record<
|
||||
SegmentType,
|
||||
@@ -22,17 +27,19 @@ interface UseSegmentSkipperProps {
|
||||
currentTime: number;
|
||||
totalDuration?: number;
|
||||
seek: (time: number) => void;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
interface UseSegmentSkipperReturn {
|
||||
currentSegment: MediaTimeSegment | null;
|
||||
skipSegment: (useHaptics?: boolean) => void;
|
||||
skipMode: SegmentSkipMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
||||
* Generic hook for a single media segment type (intro, outro, recap, commercial, preview).
|
||||
* Reports the segment currently under the playhead, its skip mode, and a skip action.
|
||||
* Auto-skip is NOT performed here: the consumer drives it from the priority-resolved
|
||||
* active segment so overlapping segments can't trigger competing seeks.
|
||||
*/
|
||||
export const useSegmentSkipper = ({
|
||||
segments,
|
||||
@@ -40,11 +47,9 @@ export const useSegmentSkipper = ({
|
||||
currentTime,
|
||||
totalDuration,
|
||||
seek,
|
||||
isPaused,
|
||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||
const { settings } = useSettings();
|
||||
const haptic = useHaptic();
|
||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
||||
|
||||
const skipMode: SegmentSkipMode =
|
||||
settings?.[SEGMENT_TO_SETTING[segmentType]] ?? "none";
|
||||
@@ -57,8 +62,9 @@ export const useSegmentSkipper = ({
|
||||
[segments, currentTime],
|
||||
);
|
||||
|
||||
// Refs let the auto-skip effect avoid re-running when skipSegment/haptic
|
||||
// identities change (haptic is unstable when disabled).
|
||||
// Refs keep skipSegment's identity stable across seek/haptic changes
|
||||
// (haptic is unstable when disabled), so the consumer's auto-skip effect
|
||||
// doesn't re-fire spuriously.
|
||||
const seekRef = useRef(seek);
|
||||
const hapticRef = useRef(haptic);
|
||||
useEffect(() => {
|
||||
@@ -90,20 +96,9 @@ export const useSegmentSkipper = ({
|
||||
[currentSegment, segmentType, totalDuration, skipMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipMode !== "auto" || isPaused || !currentSegment) {
|
||||
if (!currentSegment) autoSkipTriggeredRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentId = `${currentSegment.startTime}-${currentSegment.endTime}`;
|
||||
if (autoSkipTriggeredRef.current === segmentId) return;
|
||||
autoSkipTriggeredRef.current = segmentId;
|
||||
skipSegment(false);
|
||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
||||
|
||||
return {
|
||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
||||
skipSegment,
|
||||
skipMode,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user