mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-19 20:30:27 +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(() => {
|
||||
|
||||
Reference in New Issue
Block a user