feat(player): add media segment skip with all 5 Jellyfin segment types

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.
This commit is contained in:
Gauvain
2026-05-27 22:56:39 +02:00
parent cf91c4c682
commit e85fc77643
12 changed files with 589 additions and 375 deletions

View File

@@ -8,6 +8,7 @@ import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -15,6 +16,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PlaybackControlsSettings: React.FC = () => {
const router = useRouter();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
@@ -248,6 +250,15 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.max_auto_play_episode_count")}
/>
</ListItem>
{/* Media Segment Skip Settings */}
<ListItem
title={t("home.settings.other.segment_skip_settings")}
subtitle={t("home.settings.other.segment_skip_settings_description")}
onPress={() => router.push("/settings/segment-skip/page")}
>
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
</ListItem>
</ListGroup>
</DisabledSetting>
);

View File

@@ -18,11 +18,13 @@ interface BottomControlsProps {
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
showSkipButton: boolean;
showSkipCreditButton: boolean;
showSkipSegmentButton: boolean;
skipSegmentButtonText: string;
showSkipOutroButton: boolean;
skipOutroButtonText: string;
hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
onSkipSegment: () => void;
onSkipOutro: () => void;
nextItem?: BaseItemDto | null;
handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void;
@@ -66,11 +68,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble,
currentTime,
remainingTime,
showSkipButton,
showSkipCreditButton,
showSkipSegmentButton,
skipSegmentButtonText,
showSkipOutroButton,
skipOutroButtonText,
hasContentAfterCredits,
skipIntro,
skipCredit,
onSkipSegment,
onSkipOutro,
nextItem,
handleNextEpisodeAutoPlay,
handleNextEpisodeManual,
@@ -134,19 +138,18 @@ export const BottomControls: FC<BottomControlsProps> = ({
</View>
<View className='flex flex-row space-x-2 shrink-0'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText='Skip Intro'
showButton={showSkipSegmentButton}
onPress={onSkipSegment}
buttonText={skipSegmentButtonText}
/>
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
- Show "Next Episode" if credits extend to video end AND next episode exists */}
{/* Outro button defers to "Next Episode" when credits run to the
video end and a next episode exists. */}
<SkipButton
showButton={
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
showSkipOutroButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText='Skip Credits'
onPress={onSkipOutro}
buttonText={skipOutroButtonText}
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
@@ -157,7 +160,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
!nextItem
? false
: // Show during credits if no content after, OR near end of video
(showSkipCreditButton && !hasContentAfterCredits) ||
(showSkipOutroButton && !hasContentAfterCredits) ||
remainingTime < 10000
}
onFinish={handleNextEpisodeAutoPlay}

View File

@@ -4,7 +4,15 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams } from "expo-router";
import { type FC, useCallback, useEffect, useState } from "react";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, {
Easing,
@@ -16,17 +24,17 @@ import Animated, {
} from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
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";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { ticksToMs } from "@/utils/time";
import { useSegments } from "@/utils/segments";
import { msToSeconds, ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants";
@@ -42,6 +50,9 @@ 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;
@@ -110,6 +121,24 @@ 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,
@@ -300,27 +329,140 @@ export const Controls: FC<Props> = ({
subtitleIndex: string;
}>();
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!,
currentTime,
seek,
play,
// Fetch all segments for the current item
const { data: segments } = useSegments(
item.Id ?? "",
offline,
api,
downloadedFiles,
api,
);
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
useCreditSkipper(
item.Id!,
currentTime,
seek,
play,
offline,
api,
downloadedFiles,
maxMs,
);
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,
});
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()}`)
: t("player.skip_intro");
const skipOutroButtonText = t("player.skip_outro");
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
@@ -533,11 +675,13 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
showSkipSegmentButton={showSkipSegmentButton}
skipSegmentButtonText={skipSegmentButtonText}
showSkipOutroButton={showSkipOutroButton}
skipOutroButtonText={skipOutroButtonText}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
onSkipSegment={onSkipSegment}
onSkipOutro={onSkipOutro}
nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}