mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 01:28:27 +01:00
Closes #1312 Fixes #883 Adds a unified segment skip feature using the Jellyfin 10.11+ MediaSegments API. Replaces the legacy intro-only and credits-only hooks with a single useSegmentSkipper hook covering Intro, Outro, Recap, Commercial, and Preview. Three modes per segment type: none, ask (show button), auto (skip automatically). A dedicated submenu under Playback Controls keeps the main settings page uncluttered. Highlights: - utils/segments.ts uses getMediaSegmentsApi from @jellyfin/sdk so includeSegmentTypes is serialized as repeated keys instead of the bracket-encoded form axios produces by default (the Jellyfin server silently ignored the filter otherwise). Falls back to the pre-10.11 intro-skipper / chapter-credits plugin endpoints when the new API is unavailable. - hooks/useSegmentSkipper.ts stores seek and haptic in refs so the auto-skip effect does not re-run when their identities change (useHaptic returns a fresh no-op every render when disabled). currentSegment is memoized; the per-segment-type setting lookup uses a small map instead of a switch IIFE. - components/video-player/controls/Controls.tsx prioritizes Commercial > Recap > Intro > Preview > Outro when multiple segments overlap and exposes the active type to BottomControls via skipButtonText. - components/video-player/controls/BottomControls.tsx accepts the dynamic skipButtonText/skipCreditButtonText props. - providers/Downloads/types.ts extends DownloadedItem with the three new segment buckets for offline playback. - utils/atoms/settings.ts adds SegmentSkipMode and the five skip settings, defaulting to "ask". - app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx renders the five dropdowns from a data table. - translations/en.json and translations/fr.json add the new keys.
110 lines
3.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
|
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
|
|
import { useHaptic } from "./useHaptic";
|
|
|
|
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
|
|
|
const SEGMENT_TO_SETTING: Record<
|
|
SegmentType,
|
|
"skipIntro" | "skipOutro" | "skipRecap" | "skipCommercial" | "skipPreview"
|
|
> = {
|
|
Intro: "skipIntro",
|
|
Outro: "skipOutro",
|
|
Recap: "skipRecap",
|
|
Commercial: "skipCommercial",
|
|
Preview: "skipPreview",
|
|
};
|
|
|
|
interface UseSegmentSkipperProps {
|
|
segments: MediaTimeSegment[];
|
|
segmentType: SegmentType;
|
|
currentTime: number;
|
|
totalDuration?: number;
|
|
seek: (time: number) => void;
|
|
isPaused: boolean;
|
|
}
|
|
|
|
interface UseSegmentSkipperReturn {
|
|
currentSegment: MediaTimeSegment | null;
|
|
skipSegment: (useHaptics?: boolean) => void;
|
|
}
|
|
|
|
/**
|
|
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
|
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
|
*/
|
|
export const useSegmentSkipper = ({
|
|
segments,
|
|
segmentType,
|
|
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";
|
|
|
|
const currentSegment = useMemo(
|
|
() =>
|
|
segments.find(
|
|
(s) => currentTime >= s.startTime && currentTime < s.endTime,
|
|
) ?? null,
|
|
[segments, currentTime],
|
|
);
|
|
|
|
// Refs let the auto-skip effect avoid re-running when skipSegment/haptic
|
|
// identities change (haptic is unstable when disabled).
|
|
const seekRef = useRef(seek);
|
|
const hapticRef = useRef(haptic);
|
|
useEffect(() => {
|
|
seekRef.current = seek;
|
|
hapticRef.current = haptic;
|
|
});
|
|
|
|
const skipSegment = useCallback(
|
|
(useHaptics = true) => {
|
|
if (!currentSegment || skipMode === "none") return;
|
|
|
|
// Outro endTime sometimes exceeds the actual file duration. Keep a 2s
|
|
// buffer so the player's natural end-of-video flow (next-episode
|
|
// countdown, etc.) still fires instead of stalling at the exact end.
|
|
let target = currentSegment.endTime;
|
|
if (
|
|
segmentType === "Outro" &&
|
|
totalDuration != null &&
|
|
Number.isFinite(totalDuration) &&
|
|
target >= totalDuration
|
|
) {
|
|
target = Math.max(0, totalDuration - 2);
|
|
}
|
|
|
|
seekRef.current(target);
|
|
|
|
if (useHaptics) hapticRef.current();
|
|
},
|
|
[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,
|
|
};
|
|
};
|