From 51b70028ebd4d096e3873c198804571218fe7b4a Mon Sep 17 00:00:00 2001 From: Gauvain Date: Thu, 18 Jun 2026 18:57:25 +0200 Subject: [PATCH] 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. --- components/video-player/controls/Controls.tsx | 170 ++------------ .../video-player/controls/Controls.tv.tsx | 156 +++---------- hooks/useMediaSegments.ts | 220 ++++++++++++++++++ hooks/useSegmentSkipper.ts | 35 ++- 4 files changed, 281 insertions(+), 300 deletions(-) create mode 100644 hooks/useMediaSegments.ts diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index cf186dff..d71c579a 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -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 = ({ const [episodeView, setEpisodeView] = useState(false); const [showAudioSlider, setShowAudioSlider] = useState(false); - // Ref to track pending play timeout for cleanup and cancellation - const playTimeoutRef = useRef | 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 = ({ 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()}`) diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index d5c59b46..68a3a0f9 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -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 = ({ isSeeking, progress, cacheProgress, + isBuffering, showControls, setShowControls, mediaSource, @@ -446,129 +439,32 @@ export const Controls: FC = ({ 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 | 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); // Countdown logic const isCountdownActive = useMemo(() => { diff --git a/hooks/useMediaSegments.ts b/hooks/useMediaSegments.ts new file mode 100644 index 00000000..ba0590b2 --- /dev/null +++ b/hooks/useMediaSegments.ts @@ -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 | 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(() => { + 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(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, + }; +}; diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts index edd58845..1586eaa7 100644 --- a/hooks/useSegmentSkipper.ts +++ b/hooks/useSegmentSkipper.ts @@ -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(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, }; };