diff --git a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
new file mode 100644
index 00000000..dea043fd
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
@@ -0,0 +1,101 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useNavigation } from "expo-router";
+import { TFunction } from "i18next";
+import { useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { ListGroup } from "@/components/list/ListGroup";
+import { ListItem } from "@/components/list/ListItem";
+import { PlatformDropdown } from "@/components/PlatformDropdown";
+import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
+
+type SkipSettingKey =
+ | "skipIntro"
+ | "skipOutro"
+ | "skipRecap"
+ | "skipCommercial"
+ | "skipPreview";
+
+const SEGMENTS: ReadonlyArray<{ key: SkipSettingKey; labelKey: string }> = [
+ { key: "skipIntro", labelKey: "skip_intro" },
+ { key: "skipOutro", labelKey: "skip_outro" },
+ { key: "skipRecap", labelKey: "skip_recap" },
+ { key: "skipCommercial", labelKey: "skip_commercial" },
+ { key: "skipPreview", labelKey: "skip_preview" },
+];
+
+const SEGMENT_SKIP_OPTIONS = (
+ t: TFunction<"translation", undefined>,
+): Array<{ label: string; value: SegmentSkipMode }> => [
+ { label: t("home.settings.other.segment_skip_auto"), value: "auto" },
+ { label: t("home.settings.other.segment_skip_ask"), value: "ask" },
+ { label: t("home.settings.other.segment_skip_none"), value: "none" },
+];
+
+export default function SegmentSkipPage() {
+ const { settings, updateSettings, pluginSettings } = useSettings();
+ const { t } = useTranslation();
+ const navigation = useNavigation();
+
+ useEffect(() => {
+ navigation.setOptions({
+ title: t("home.settings.other.segment_skip_settings"),
+ });
+ }, [navigation, t]);
+
+ const options = useMemo(() => SEGMENT_SKIP_OPTIONS(t), [t]);
+
+ if (!settings) return null;
+
+ return (
+
+
+ {SEGMENTS.map(({ key, labelKey }) => {
+ const current = settings[key];
+ const locked = pluginSettings?.[key]?.locked ?? false;
+ const groups = [
+ {
+ options: options.map((o) => ({
+ type: "radio" as const,
+ label: o.label,
+ value: o.value,
+ selected: o.value === current,
+ disabled: locked,
+ onPress: () => {
+ if (locked) return;
+ updateSettings({ [key]: o.value });
+ },
+ })),
+ },
+ ];
+ return (
+
+
+
+ {t(`home.settings.other.segment_skip_${current}`)}
+
+
+
+ }
+ title={t(`home.settings.other.${labelKey}`)}
+ />
+
+ );
+ })}
+
+
+ );
+}
diff --git a/components/settings/PlaybackControlsSettings.tsx b/components/settings/PlaybackControlsSettings.tsx
index ad6a215e..7a1a8d2b 100644
--- a/components/settings/PlaybackControlsSettings.tsx
+++ b/components/settings/PlaybackControlsSettings.tsx
@@ -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")}
/>
+
+ {/* Media Segment Skip Settings */}
+ router.push("/settings/segment-skip/page")}
+ >
+
+
);
diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx
index 51abf68c..81186e3d 100644
--- a/components/video-player/controls/BottomControls.tsx
+++ b/components/video-player/controls/BottomControls.tsx
@@ -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 = ({
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 = ({
- {/* 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. */}
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
@@ -157,7 +160,7 @@ export const BottomControls: FC = ({
!nextItem
? false
: // Show during credits if no content after, OR near end of video
- (showSkipCreditButton && !hasContentAfterCredits) ||
+ (showSkipOutroButton && !hasContentAfterCredits) ||
remainingTime < 10000
}
onFinish={handleNextEpisodeAutoPlay}
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index 96dfad6b..028a9185 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -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 = ({
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,
@@ -300,27 +329,140 @@ export const Controls: FC = ({
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 = ({
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}
diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts
deleted file mode 100644
index 40c1d695..00000000
--- a/hooks/useCreditSkipper.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { Api } from "@jellyfin/sdk";
-import { useCallback, useEffect, useState } from "react";
-import { DownloadedItem } from "@/providers/Downloads/types";
-import { useSegments } from "@/utils/segments";
-import { msToSeconds, secondsToMs } from "@/utils/time";
-import { useHaptic } from "./useHaptic";
-
-/**
- * Custom hook to handle skipping credits in a media player.
- * The player reports time values in milliseconds.
- */
-export const useCreditSkipper = (
- itemId: string,
- currentTime: number,
- seek: (ms: number) => void,
- play: () => void,
- isOffline = false,
- api: Api | null = null,
- downloadedFiles: DownloadedItem[] | undefined = undefined,
- totalDuration?: number,
-) => {
- const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
- const lightHapticFeedback = useHaptic("light");
-
- // Convert ms to seconds for comparison with timestamps
- const currentTimeSeconds = msToSeconds(currentTime);
-
- const totalDurationInSeconds =
- totalDuration != null ? msToSeconds(totalDuration) : undefined;
-
- // Regular function (not useCallback) to match useIntroSkipper pattern
- const wrappedSeek = (seconds: number) => {
- seek(secondsToMs(seconds));
- };
-
- const { data: segments } = useSegments(
- itemId,
- isOffline,
- downloadedFiles,
- api,
- );
- const creditTimestamps = segments?.creditSegments?.[0];
-
- // Determine if there's content after credits (credits don't extend to video end)
- // Use a 5-second buffer to account for timing discrepancies
- const hasContentAfterCredits = (() => {
- if (
- !creditTimestamps ||
- totalDurationInSeconds == null ||
- !Number.isFinite(totalDurationInSeconds)
- ) {
- return false;
- }
- const creditsEndToVideoEnd =
- totalDurationInSeconds - creditTimestamps.endTime;
- // If credits end more than 5 seconds before video ends, there's content after
- return creditsEndToVideoEnd > 5;
- })();
-
- useEffect(() => {
- if (creditTimestamps) {
- const shouldShow =
- currentTimeSeconds > creditTimestamps.startTime &&
- currentTimeSeconds < creditTimestamps.endTime;
-
- setShowSkipCreditButton(shouldShow);
- } else {
- // Reset button state when no credit timestamps exist
- if (showSkipCreditButton) {
- setShowSkipCreditButton(false);
- }
- }
- }, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
-
- const skipCredit = useCallback(() => {
- if (!creditTimestamps) return;
-
- try {
- lightHapticFeedback();
-
- // Calculate the target seek position
- let seekTarget = creditTimestamps.endTime;
-
- // If we have total duration, ensure we don't seek past the end of the video.
- // Some media sources report credit end times that exceed the actual video duration,
- // which causes the player to pause/stop when seeking past the end.
- // Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
- // (next episode countdown, etc.) instead of an abrupt pause.
- if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
- seekTarget = Math.max(0, totalDurationInSeconds - 2);
- }
-
- wrappedSeek(seekTarget);
- setTimeout(() => {
- play();
- }, 200);
- } catch (error) {
- console.error("[CREDIT_SKIPPER] Error skipping credit", error);
- }
- }, [
- creditTimestamps,
- lightHapticFeedback,
- wrappedSeek,
- play,
- totalDurationInSeconds,
- ]);
-
- return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
-};
diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts
deleted file mode 100644
index eeed9833..00000000
--- a/hooks/useIntroSkipper.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Api } from "@jellyfin/sdk";
-import { useCallback, useEffect, useState } from "react";
-import { DownloadedItem } from "@/providers/Downloads/types";
-import { useSegments } from "@/utils/segments";
-import { msToSeconds, secondsToMs } from "@/utils/time";
-import { useHaptic } from "./useHaptic";
-
-/**
- * Custom hook to handle skipping intros in a media player.
- * MPV player uses milliseconds for time.
- *
- * @param {number} currentTime - The current playback time in milliseconds.
- */
-export const useIntroSkipper = (
- itemId: string,
- currentTime: number,
- seek: (ms: number) => void,
- play: () => void,
- isOffline = false,
- api: Api | null = null,
- downloadedFiles: DownloadedItem[] | undefined = undefined,
-) => {
- const [showSkipButton, setShowSkipButton] = useState(false);
- // Convert ms to seconds for comparison with timestamps
- const currentTimeSeconds = msToSeconds(currentTime);
- const lightHapticFeedback = useHaptic("light");
-
- const wrappedSeek = (seconds: number) => {
- seek(secondsToMs(seconds));
- };
-
- const { data: segments } = useSegments(
- itemId,
- isOffline,
- downloadedFiles,
- api,
- );
- const introTimestamps = segments?.introSegments?.[0];
-
- useEffect(() => {
- if (introTimestamps) {
- const shouldShow =
- currentTimeSeconds > introTimestamps.startTime &&
- currentTimeSeconds < introTimestamps.endTime;
-
- setShowSkipButton(shouldShow);
- } else {
- if (showSkipButton) {
- setShowSkipButton(false);
- }
- }
- }, [introTimestamps, currentTimeSeconds, showSkipButton]);
-
- const skipIntro = useCallback(() => {
- if (!introTimestamps) return;
- try {
- lightHapticFeedback();
- wrappedSeek(introTimestamps.endTime);
- setTimeout(() => {
- play();
- }, 200);
- } catch (error) {
- console.error("[INTRO_SKIPPER] Error skipping intro", error);
- }
- }, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
-
- return { showSkipButton, skipIntro };
-};
diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts
new file mode 100644
index 00000000..edd58845
--- /dev/null
+++ b/hooks/useSegmentSkipper.ts
@@ -0,0 +1,109 @@
+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(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,
+ };
+};
diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts
index 976a6e23..f1a57efc 100644
--- a/providers/Downloads/types.ts
+++ b/providers/Downloads/types.ts
@@ -32,12 +32,6 @@ export interface MediaTimeSegment {
text: string;
}
-export interface Segment {
- startTime: number;
- endTime: number;
- text: string;
-}
-
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
export interface DownloadedItem {
/** The Jellyfin item DTO. */
@@ -56,6 +50,12 @@ export interface DownloadedItem {
introSegments?: MediaTimeSegment[];
/** The credit segments for the item. */
creditSegments?: MediaTimeSegment[];
+ /** The recap segments for the item. */
+ recapSegments?: MediaTimeSegment[];
+ /** The commercial segments for the item. */
+ commercialSegments?: MediaTimeSegment[];
+ /** The preview segments for the item. */
+ previewSegments?: MediaTimeSegment[];
/** The user data for the item. */
userData: UserData;
}
@@ -144,6 +144,12 @@ export type JobStatus = {
introSegments?: MediaTimeSegment[];
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
creditSegments?: MediaTimeSegment[];
+ /** Pre-downloaded recap segments (optional) - downloaded before video starts */
+ recapSegments?: MediaTimeSegment[];
+ /** Pre-downloaded commercial segments (optional) - downloaded before video starts */
+ commercialSegments?: MediaTimeSegment[];
+ /** Pre-downloaded preview segments (optional) - downloaded before video starts */
+ previewSegments?: MediaTimeSegment[];
/** The audio stream index selected for this download */
audioStreamIndex?: number;
/** The subtitle stream index selected for this download */
diff --git a/translations/en.json b/translations/en.json
index 3fe9efb6..5b99e6df 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -24,6 +24,31 @@
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
+ "player": {
+ "skip_intro": "Skip Intro",
+ "skip_outro": "Skip Outro",
+ "skip_recap": "Skip Recap",
+ "skip_commercial": "Skip Commercial",
+ "skip_preview": "Skip Preview",
+ "error": "Error",
+ "failed_to_get_stream_url": "Failed to get the stream URL",
+ "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
+ "client_error": "Client Error",
+ "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
+ "message_from_server": "Message from Server: {{message}}",
+ "next_episode": "Next Episode",
+ "refresh_tracks": "Refresh Tracks",
+ "audio_tracks": "Audio Tracks:",
+ "playback_state": "Playback State:",
+ "index": "Index:",
+ "continue_watching": "Continue Watching",
+ "go_back": "Go Back",
+ "downloaded_file_title": "You have this file downloaded",
+ "downloaded_file_message": "Do you want to play the downloaded file?",
+ "downloaded_file_yes": "Yes",
+ "downloaded_file_no": "No",
+ "downloaded_file_cancel": "Cancel"
+ },
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",
@@ -308,6 +333,21 @@
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "Max Auto Play Episode Count",
+ "segment_skip_settings": "Segment Skip Settings",
+ "segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
+ "skip_intro": "Skip Intro",
+ "skip_intro_description": "Action when intro segment is detected",
+ "skip_outro": "Skip Outro/Credits",
+ "skip_outro_description": "Action when outro/credits segment is detected",
+ "skip_recap": "Skip Recap",
+ "skip_recap_description": "Action when recap segment is detected",
+ "skip_commercial": "Skip Commercial",
+ "skip_commercial_description": "Action when commercial segment is detected",
+ "skip_preview": "Skip Preview",
+ "skip_preview_description": "Action when preview segment is detected",
+ "segment_skip_none": "None",
+ "segment_skip_ask": "Show Skip Button",
+ "segment_skip_auto": "Auto Skip",
"disabled": "Disabled"
},
"downloads": {
@@ -590,26 +630,6 @@
"custom_links": {
"no_links": "No Links"
},
- "player": {
- "error": "Error",
- "failed_to_get_stream_url": "Failed to get the stream URL",
- "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
- "client_error": "Client Error",
- "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
- "message_from_server": "Message from Server: {{message}}",
- "next_episode": "Next Episode",
- "refresh_tracks": "Refresh Tracks",
- "audio_tracks": "Audio Tracks:",
- "playback_state": "Playback State:",
- "index": "Index:",
- "continue_watching": "Continue Watching",
- "go_back": "Go Back",
- "downloaded_file_title": "You have this file downloaded",
- "downloaded_file_message": "Do you want to play the downloaded file?",
- "downloaded_file_yes": "Yes",
- "downloaded_file_no": "No",
- "downloaded_file_cancel": "Cancel"
- },
"item_card": {
"next_up": "Next Up",
"no_items_to_display": "No Items to Display",
diff --git a/translations/fr.json b/translations/fr.json
index b2663cd6..bde41a73 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -308,7 +308,22 @@
"default_playback_speed": "Vitesse de lecture par défaut",
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
- "disabled": "Désactivé"
+ "disabled": "Désactivé",
+ "segment_skip_settings": "Saut de segments",
+ "segment_skip_settings_description": "Configurer le saut pour les intros, génériques et autres segments",
+ "skip_intro": "Sauter l'intro",
+ "skip_intro_description": "Action lorsqu'un segment d'intro est détecté",
+ "skip_outro": "Sauter générique / outro",
+ "skip_outro_description": "Action lorsqu'un segment de générique/outro est détecté",
+ "skip_recap": "Sauter le résumé",
+ "skip_recap_description": "Action lorsqu'un segment de résumé est détecté",
+ "skip_commercial": "Sauter la publicité",
+ "skip_commercial_description": "Action lorsqu'un segment publicitaire est détecté",
+ "skip_preview": "Sauter l'aperçu",
+ "skip_preview_description": "Action lorsqu'un segment d'aperçu est détecté",
+ "segment_skip_none": "Aucune",
+ "segment_skip_ask": "Afficher le bouton",
+ "segment_skip_auto": "Saut automatique"
},
"downloads": {
"downloads_title": "Téléchargements"
@@ -591,6 +606,11 @@
"no_links": "Aucuns liens"
},
"player": {
+ "skip_intro": "Passer l'intro",
+ "skip_outro": "Passer l'outro",
+ "skip_recap": "Passer le résumé",
+ "skip_commercial": "Passer la pub",
+ "skip_preview": "Passer l'aperçu",
"error": "Erreur",
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index 28f7d1b4..09573965 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -134,6 +134,9 @@ export enum VideoPlayer {
MPV = 0,
}
+// Segment skip behavior options
+export type SegmentSkipMode = "none" | "ask" | "auto";
+
// Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices
@@ -181,6 +184,12 @@ export type Settings = {
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number;
autoPlayNextEpisode: boolean;
+ // Media segment skip preferences
+ skipIntro: SegmentSkipMode;
+ skipOutro: SegmentSkipMode;
+ skipRecap: SegmentSkipMode;
+ skipCommercial: SegmentSkipMode;
+ skipPreview: SegmentSkipMode;
// Playback speed settings
defaultPlaybackSpeed: number;
playbackSpeedPerMedia: Record;
@@ -266,6 +275,12 @@ export const defaultValues: Settings = {
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0,
autoPlayNextEpisode: true,
+ // Media segment skip defaults
+ skipIntro: "ask",
+ skipOutro: "ask",
+ skipRecap: "ask",
+ skipCommercial: "ask",
+ skipPreview: "ask",
// Playback speed defaults
defaultPlaybackSpeed: 1.0,
playbackSpeedPerMedia: {},
diff --git a/utils/segments.ts b/utils/segments.ts
index c55b1da5..d49984c9 100644
--- a/utils/segments.ts
+++ b/utils/segments.ts
@@ -1,46 +1,40 @@
import { Api } from "@jellyfin/sdk";
+import { MediaSegmentType } from "@jellyfin/sdk/lib/generated-client/models/media-segment-type";
+import { getMediaSegmentsApi } from "@jellyfin/sdk/lib/utils/api/media-segments-api";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
import { getAuthHeaders } from "./jellyfin/jellyfin";
-// New Jellyfin 10.11+ Media Segments API types
-interface MediaSegmentDto {
- Id: string;
- ItemId: string;
- Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
- StartTicks: number;
- EndTicks: number;
+export interface SegmentBuckets {
+ introSegments: MediaTimeSegment[];
+ creditSegments: MediaTimeSegment[];
+ recapSegments: MediaTimeSegment[];
+ commercialSegments: MediaTimeSegment[];
+ previewSegments: MediaTimeSegment[];
}
-interface MediaSegmentsResponse {
- Items: MediaSegmentDto[];
-}
-
-// Legacy API types (for fallback)
+// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
interface IntroTimestamps {
- EpisodeId: string;
- HideSkipPromptAt: number;
- IntroEnd: number;
IntroStart: number;
- ShowSkipPromptAt: number;
+ IntroEnd: number;
Valid: boolean;
}
interface CreditTimestamps {
- Introduction: {
- Start: number;
- End: number;
- Valid: boolean;
- };
- Credits: {
- Start: number;
- End: number;
- Valid: boolean;
- };
+ Credits: { Start: number; End: number; Valid: boolean };
}
-const TICKS_PER_SECOND = 10000000;
+const TICKS_PER_SECOND = 10_000_000;
+const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
+
+const emptyBuckets = (): SegmentBuckets => ({
+ introSegments: [],
+ creditSegments: [],
+ recapSegments: [],
+ commercialSegments: [],
+ previewSegments: [],
+});
export const useSegments = (
itemId: string,
@@ -48,7 +42,6 @@ export const useSegments = (
downloadedFiles: DownloadedItem[] | undefined,
api: Api | null,
) => {
- // Memoize the lookup so the array is only traversed when dependencies change
const downloadedItem = React.useMemo(
() => downloadedFiles?.find((d) => d.item.Id === itemId),
[downloadedFiles, itemId],
@@ -65,141 +58,110 @@ export const useSegments = (
}
return fetchAndParseSegments(itemId, api);
},
- enabled: isOffline ? !!downloadedItem : !!api,
+ enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
});
};
-export const getSegmentsForItem = (
- item: DownloadedItem,
-): {
- introSegments: MediaTimeSegment[];
- creditSegments: MediaTimeSegment[];
-} => {
- return {
- introSegments: item.introSegments || [],
- creditSegments: item.creditSegments || [],
- };
-};
+export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
+ introSegments: item.introSegments || [],
+ creditSegments: item.creditSegments || [],
+ recapSegments: item.recapSegments || [],
+ commercialSegments: item.commercialSegments || [],
+ previewSegments: item.previewSegments || [],
+});
-/**
- * Converts Jellyfin ticks to seconds
- */
-const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
-
-/**
- * Fetches segments using the new Jellyfin 10.11+ MediaSegments API
- */
+/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
const fetchMediaSegments = async (
itemId: string,
api: Api,
-): Promise<{
- introSegments: MediaTimeSegment[];
- creditSegments: MediaTimeSegment[];
-} | null> => {
+): Promise => {
try {
- const response = await api.axiosInstance.get(
- `${api.basePath}/MediaSegments/${itemId}`,
- {
- headers: getAuthHeaders(api),
- params: {
- includeSegmentTypes: ["Intro", "Outro"],
- },
- },
- );
+ const response = await getMediaSegmentsApi(api).getItemSegments({
+ itemId,
+ includeSegmentTypes: [
+ MediaSegmentType.Intro,
+ MediaSegmentType.Outro,
+ MediaSegmentType.Recap,
+ MediaSegmentType.Commercial,
+ MediaSegmentType.Preview,
+ ],
+ });
- const introSegments: MediaTimeSegment[] = [];
- const creditSegments: MediaTimeSegment[] = [];
-
- response.data.Items.forEach((segment) => {
+ const buckets = emptyBuckets();
+ for (const segment of response.data.Items ?? []) {
+ if (segment.StartTicks == null || segment.EndTicks == null) continue;
const timeSegment: MediaTimeSegment = {
startTime: ticksToSeconds(segment.StartTicks),
endTime: ticksToSeconds(segment.EndTicks),
- text: segment.Type,
+ text: segment.Type ?? "",
};
switch (segment.Type) {
- case "Intro":
- introSegments.push(timeSegment);
+ case MediaSegmentType.Intro:
+ buckets.introSegments.push(timeSegment);
break;
- case "Outro":
- creditSegments.push(timeSegment);
+ case MediaSegmentType.Outro:
+ buckets.creditSegments.push(timeSegment);
break;
- // Optionally handle other types like Recap, Commercial, Preview
- default:
+ case MediaSegmentType.Recap:
+ buckets.recapSegments.push(timeSegment);
+ break;
+ case MediaSegmentType.Commercial:
+ buckets.commercialSegments.push(timeSegment);
+ break;
+ case MediaSegmentType.Preview:
+ buckets.previewSegments.push(timeSegment);
break;
}
- });
+ }
- return { introSegments, creditSegments };
- } catch (_error) {
- // Return null to indicate we should try legacy endpoints
+ return buckets;
+ } catch {
return null;
}
};
-/**
- * Fetches segments using legacy pre-10.11 endpoints
- */
+/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
const fetchLegacySegments = async (
itemId: string,
api: Api,
-): Promise<{
- introSegments: MediaTimeSegment[];
- creditSegments: MediaTimeSegment[];
-}> => {
- const introSegments: MediaTimeSegment[] = [];
- const creditSegments: MediaTimeSegment[] = [];
+): Promise => {
+ const buckets = emptyBuckets();
- try {
- const [introRes, creditRes] = await Promise.allSettled([
- api.axiosInstance.get(
- `${api.basePath}/Episode/${itemId}/IntroTimestamps`,
- { headers: getAuthHeaders(api) },
- ),
- api.axiosInstance.get(
- `${api.basePath}/Episode/${itemId}/Timestamps`,
- { headers: getAuthHeaders(api) },
- ),
- ]);
+ const [introRes, creditRes] = await Promise.allSettled([
+ api.axiosInstance.get(
+ `${api.basePath}/Episode/${itemId}/IntroTimestamps`,
+ { headers: getAuthHeaders(api) },
+ ),
+ api.axiosInstance.get(
+ `${api.basePath}/Episode/${itemId}/Timestamps`,
+ { headers: getAuthHeaders(api) },
+ ),
+ ]);
- if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
- introSegments.push({
- startTime: introRes.value.data.IntroStart,
- endTime: introRes.value.data.IntroEnd,
- text: "Intro",
- });
- }
-
- if (
- creditRes.status === "fulfilled" &&
- creditRes.value.data.Credits.Valid
- ) {
- creditSegments.push({
- startTime: creditRes.value.data.Credits.Start,
- endTime: creditRes.value.data.Credits.End,
- text: "Credits",
- });
- }
- } catch (error) {
- console.error("Failed to fetch legacy segments", error);
+ if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
+ buckets.introSegments.push({
+ startTime: introRes.value.data.IntroStart,
+ endTime: introRes.value.data.IntroEnd,
+ text: "Intro",
+ });
}
- return { introSegments, creditSegments };
+ if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
+ buckets.creditSegments.push({
+ startTime: creditRes.value.data.Credits.Start,
+ endTime: creditRes.value.data.Credits.End,
+ text: "Outro",
+ });
+ }
+
+ return buckets;
};
export const fetchAndParseSegments = async (
itemId: string,
api: Api,
-): Promise<{
- introSegments: MediaTimeSegment[];
- creditSegments: MediaTimeSegment[];
-}> => {
- // Try new API first (Jellyfin 10.11+)
+): Promise => {
const newSegments = await fetchMediaSegments(itemId, api);
- if (newSegments) {
- return newSegments;
- }
-
- // Fallback to legacy endpoints
- return fetchLegacySegments(itemId, api);
+ return newSegments ?? fetchLegacySegments(itemId, api);
};