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:
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