mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-02 18:42:51 +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,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import {
|
import { type FC, useCallback, useEffect, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -25,8 +18,8 @@ import Animated, {
|
|||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useMediaSegments } from "@/hooks/useMediaSegments";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
@@ -34,7 +27,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
import { useSegments } from "@/utils/segments";
|
||||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
@@ -51,9 +44,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
|||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||||
|
|
||||||
// No-op function to avoid creating new references on every render
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -122,24 +112,6 @@ export const Controls: FC<Props> = ({
|
|||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [showAudioSlider, setShowAudioSlider] = 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 { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||||
const { previousItem, nextItem } = usePlaybackManager({
|
const { previousItem, nextItem } = usePlaybackManager({
|
||||||
item,
|
item,
|
||||||
@@ -353,127 +325,25 @@ export const Controls: FC<Props> = ({
|
|||||||
api,
|
api,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTimeSeconds = msToSeconds(currentTime);
|
// Unified segment orchestration (identical mechanism on mobile and TV):
|
||||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
// overlap priority + a single auto-skip driver live in the shared hook.
|
||||||
|
const {
|
||||||
// Segment hook deals in seconds; player API in ms. The 200ms delayed play()
|
activeSegment,
|
||||||
// is a workaround: some seeks otherwise resume from the pre-seek position.
|
skipActiveSegment: onSkipSegment,
|
||||||
const seekMs = useCallback(
|
showSkipButton: showSkipSegmentButton,
|
||||||
(timeInSeconds: number) => {
|
isOutroActive: showSkipOutroButton,
|
||||||
if (playTimeoutRef.current) {
|
skipOutro: onSkipOutro,
|
||||||
clearTimeout(playTimeoutRef.current);
|
hasContentAfterCredits,
|
||||||
}
|
} = useMediaSegments({
|
||||||
seek(timeInSeconds * 1000);
|
segments,
|
||||||
playTimeoutRef.current = setTimeout(() => {
|
currentTime,
|
||||||
// playingRef avoids a stale closure: re-check current isPlaying.
|
maxMs,
|
||||||
if (playingRef.current) {
|
seek,
|
||||||
play();
|
play,
|
||||||
}
|
isPlaying,
|
||||||
playTimeoutRef.current = null;
|
isBuffering,
|
||||||
}, 200);
|
|
||||||
},
|
|
||||||
[seek, play],
|
|
||||||
);
|
|
||||||
|
|
||||||
const introSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.introSegments || [],
|
|
||||||
segmentType: "Intro",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekMs,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 { t } = useTranslation();
|
||||||
const skipSegmentButtonText = activeSegment
|
const skipSegmentButtonText = activeSegment
|
||||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ import {
|
|||||||
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useMediaSegments } from "@/hooks/useMediaSegments";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
import type { SegmentType } from "@/hooks/useSegmentSkipper";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
@@ -51,13 +52,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
import { useSegments } from "@/utils/segments";
|
||||||
import {
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||||
formatTimeString,
|
|
||||||
msToSeconds,
|
|
||||||
msToTicks,
|
|
||||||
secondsToMs,
|
|
||||||
ticksToMs,
|
|
||||||
} from "@/utils/time";
|
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useVideoContext } from "./contexts/VideoContext";
|
import { useVideoContext } from "./contexts/VideoContext";
|
||||||
import { useChapterNavigation } from "./hooks/useChapterNavigation";
|
import { useChapterNavigation } from "./hooks/useChapterNavigation";
|
||||||
@@ -105,9 +100,6 @@ interface Props {
|
|||||||
const TV_SEEKBAR_HEIGHT = 14;
|
const TV_SEEKBAR_HEIGHT = 14;
|
||||||
const TV_AUTO_HIDE_TIMEOUT = 5000;
|
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
|
// Trickplay bubble positioning constants
|
||||||
const TV_TRICKPLAY_SCALE = 2;
|
const TV_TRICKPLAY_SCALE = 2;
|
||||||
const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
|
const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
|
||||||
@@ -208,6 +200,7 @@ export const Controls: FC<Props> = ({
|
|||||||
isSeeking,
|
isSeeking,
|
||||||
progress,
|
progress,
|
||||||
cacheProgress,
|
cacheProgress,
|
||||||
|
isBuffering,
|
||||||
showControls,
|
showControls,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
@@ -446,129 +439,32 @@ export const Controls: FC<Props> = ({
|
|||||||
api,
|
api,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTimeSeconds = msToSeconds(currentTime);
|
// Unified segment orchestration (identical mechanism on mobile and TV):
|
||||||
const maxSeconds = msToSeconds(maxMs);
|
// overlap priority + a single auto-skip driver live in the shared hook.
|
||||||
|
const {
|
||||||
// useSegmentSkipper deals in seconds; the player seek expects ms. The 200ms
|
activeSegment,
|
||||||
// delayed play() mirrors the mobile controls: some seeks otherwise resume
|
skipActiveSegment,
|
||||||
// from the pre-seek position.
|
showSkipButton,
|
||||||
const playSegmentTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
isOutroActive,
|
||||||
null,
|
skipOutro: skipCredit,
|
||||||
);
|
hasContentAfterCredits,
|
||||||
useEffect(() => {
|
} = useMediaSegments({
|
||||||
return () => {
|
segments,
|
||||||
if (playSegmentTimeoutRef.current) {
|
currentTime,
|
||||||
clearTimeout(playSegmentTimeoutRef.current);
|
maxMs,
|
||||||
}
|
seek,
|
||||||
};
|
play: _play,
|
||||||
}, []);
|
isPlaying,
|
||||||
|
isBuffering,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// The outro keeps its dedicated card (it composes with the Next Episode
|
||||||
// countdown); the other four share one generic skip card. Including the outro
|
// countdown); the other four share the generic skip card.
|
||||||
// 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).
|
|
||||||
const showSkipCreditButton = isOutroActive;
|
const showSkipCreditButton = isOutroActive;
|
||||||
const skipCredit = outroSkipper.skipSegment;
|
const activeSegmentType =
|
||||||
const hasContentAfterCredits =
|
isOutroActive || !activeSegment
|
||||||
outroSkipper.currentSegment && maxSeconds
|
? "intro"
|
||||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
: (activeSegment.type.toLowerCase() as Lowercase<SegmentType>);
|
||||||
: false;
|
|
||||||
|
|
||||||
// Countdown logic
|
// Countdown logic
|
||||||
const isCountdownActive = useMemo(() => {
|
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 { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
|
||||||
import { useHaptic } from "./useHaptic";
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
export type SegmentType =
|
||||||
|
| "Intro"
|
||||||
|
| "Outro"
|
||||||
|
| "Recap"
|
||||||
|
| "Commercial"
|
||||||
|
| "Preview";
|
||||||
|
|
||||||
const SEGMENT_TO_SETTING: Record<
|
const SEGMENT_TO_SETTING: Record<
|
||||||
SegmentType,
|
SegmentType,
|
||||||
@@ -22,17 +27,19 @@ interface UseSegmentSkipperProps {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
totalDuration?: number;
|
totalDuration?: number;
|
||||||
seek: (time: number) => void;
|
seek: (time: number) => void;
|
||||||
isPaused: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseSegmentSkipperReturn {
|
interface UseSegmentSkipperReturn {
|
||||||
currentSegment: MediaTimeSegment | null;
|
currentSegment: MediaTimeSegment | null;
|
||||||
skipSegment: (useHaptics?: boolean) => void;
|
skipSegment: (useHaptics?: boolean) => void;
|
||||||
|
skipMode: SegmentSkipMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
* Generic hook for a single media segment type (intro, outro, recap, commercial, preview).
|
||||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
* 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 = ({
|
export const useSegmentSkipper = ({
|
||||||
segments,
|
segments,
|
||||||
@@ -40,11 +47,9 @@ export const useSegmentSkipper = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
totalDuration,
|
totalDuration,
|
||||||
seek,
|
seek,
|
||||||
isPaused,
|
|
||||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const skipMode: SegmentSkipMode =
|
const skipMode: SegmentSkipMode =
|
||||||
settings?.[SEGMENT_TO_SETTING[segmentType]] ?? "none";
|
settings?.[SEGMENT_TO_SETTING[segmentType]] ?? "none";
|
||||||
@@ -57,8 +62,9 @@ export const useSegmentSkipper = ({
|
|||||||
[segments, currentTime],
|
[segments, currentTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refs let the auto-skip effect avoid re-running when skipSegment/haptic
|
// Refs keep skipSegment's identity stable across seek/haptic changes
|
||||||
// identities change (haptic is unstable when disabled).
|
// (haptic is unstable when disabled), so the consumer's auto-skip effect
|
||||||
|
// doesn't re-fire spuriously.
|
||||||
const seekRef = useRef(seek);
|
const seekRef = useRef(seek);
|
||||||
const hapticRef = useRef(haptic);
|
const hapticRef = useRef(haptic);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,20 +96,9 @@ export const useSegmentSkipper = ({
|
|||||||
[currentSegment, segmentType, totalDuration, skipMode],
|
[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 {
|
return {
|
||||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
currentSegment: skipMode === "none" ? null : currentSegment,
|
||||||
skipSegment,
|
skipSegment,
|
||||||
|
skipMode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user