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:
Gauvain
2026-06-18 18:57:25 +02:00
parent 9f2f5e4ec1
commit dd18c13c8a
4 changed files with 281 additions and 300 deletions

220
hooks/useMediaSegments.ts Normal file
View 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,
};
};

View File

@@ -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,
};
};