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..9a924f87
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
@@ -0,0 +1,246 @@
+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 DisabledSetting from "@/components/settings/DisabledSetting";
+import { useSettings } from "@/utils/atoms/settings";
+
+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 skipIntroOptions = useMemo(
+ () => [
+ {
+ options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: option.value,
+ selected: option.value === settings.skipIntro,
+ onPress: () => updateSettings({ skipIntro: option.value }),
+ })),
+ },
+ ],
+ [settings.skipIntro, updateSettings, t],
+ );
+
+ const skipOutroOptions = useMemo(
+ () => [
+ {
+ options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: option.value,
+ selected: option.value === settings.skipOutro,
+ onPress: () => updateSettings({ skipOutro: option.value }),
+ })),
+ },
+ ],
+ [settings.skipOutro, updateSettings, t],
+ );
+
+ const skipRecapOptions = useMemo(
+ () => [
+ {
+ options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: option.value,
+ selected: option.value === settings.skipRecap,
+ onPress: () => updateSettings({ skipRecap: option.value }),
+ })),
+ },
+ ],
+ [settings.skipRecap, updateSettings, t],
+ );
+
+ const skipCommercialOptions = useMemo(
+ () => [
+ {
+ options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: option.value,
+ selected: option.value === settings.skipCommercial,
+ onPress: () => updateSettings({ skipCommercial: option.value }),
+ })),
+ },
+ ],
+ [settings.skipCommercial, updateSettings, t],
+ );
+
+ const skipPreviewOptions = useMemo(
+ () => [
+ {
+ options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: option.value,
+ selected: option.value === settings.skipPreview,
+ onPress: () => updateSettings({ skipPreview: option.value }),
+ })),
+ },
+ ],
+ [settings.skipPreview, updateSettings, t],
+ );
+
+ if (!settings) return null;
+
+ return (
+
+
+
+
+
+ {t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
+
+
+
+ }
+ title={t("home.settings.other.skip_intro")}
+ />
+
+
+
+
+
+ {t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
+
+
+
+ }
+ title={t("home.settings.other.skip_outro")}
+ />
+
+
+
+
+
+ {t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
+
+
+
+ }
+ title={t("home.settings.other.skip_recap")}
+ />
+
+
+
+
+
+ {t(
+ `home.settings.other.segment_skip_${settings.skipCommercial}`,
+ )}
+
+
+
+ }
+ title={t("home.settings.other.skip_commercial")}
+ />
+
+
+
+
+
+ {t(
+ `home.settings.other.segment_skip_${settings.skipPreview}`,
+ )}
+
+
+
+ }
+ title={t("home.settings.other.skip_preview")}
+ />
+
+
+
+ );
+}
+
+const SEGMENT_SKIP_OPTIONS = (
+ t: TFunction<"translation", undefined>,
+): Array<{
+ label: string;
+ value: "none" | "ask" | "auto";
+}> => [
+ {
+ label: t("home.settings.other.segment_skip_none"),
+ value: "none",
+ },
+ {
+ label: t("home.settings.other.segment_skip_ask"),
+ value: "ask",
+ },
+ {
+ label: t("home.settings.other.segment_skip_auto"),
+ value: "auto",
+ },
+];
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..46749009 100644
--- a/components/video-player/controls/BottomControls.tsx
+++ b/components/video-player/controls/BottomControls.tsx
@@ -19,6 +19,7 @@ interface BottomControlsProps {
currentTime: number;
remainingTime: number;
showSkipButton: boolean;
+ skipButtonText: string;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
skipIntro: () => void;
@@ -67,6 +68,7 @@ export const BottomControls: FC = ({
currentTime,
remainingTime,
showSkipButton,
+ skipButtonText,
showSkipCreditButton,
hasContentAfterCredits,
skipIntro,
@@ -136,7 +138,7 @@ export const BottomControls: FC = ({
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index 96dfad6b..4897bd8a 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -16,17 +16,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";
@@ -300,27 +300,101 @@ export const Controls: FC = ({
subtitleIndex: string;
}>();
- const { showSkipButton, skipIntro } = useIntroSkipper(
+ // Fetch all segments for the current item
+ const { data: segments } = useSegments(
item.Id!,
- currentTime,
- seek,
- play,
offline,
- api,
downloadedFiles,
+ api,
);
- const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
- useCreditSkipper(
- item.Id!,
- currentTime,
- seek,
- play,
- offline,
- api,
- downloadedFiles,
- maxMs,
- );
+ // Convert milliseconds to seconds for segment comparison
+ const currentTimeSeconds = msToSeconds(currentTime);
+ const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
+
+ // Wrapper to convert segment skip from seconds to milliseconds
+ const seekMs = useCallback(
+ (timeInSeconds: number) => {
+ seek(timeInSeconds * 1000);
+ setTimeout(() => {
+ play();
+ }, 200);
+ },
+ [seek, play],
+ );
+
+ // Use unified segment skipper for all segment types
+ 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,
+ });
+
+ // Determine which segment button to show (priority order)
+ // Commercial > Recap > Intro > Preview > Outro
+ const activeSegment = (() => {
+ if (commercialSkipper.currentSegment)
+ return { type: "Commercial", ...commercialSkipper };
+ if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
+ if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
+ if (previewSkipper.currentSegment)
+ return { type: "Preview", ...previewSkipper };
+ if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
+ return null;
+ })();
+
+ // Legacy compatibility: map to old variable names
+ const showSkipButton = !!(
+ activeSegment &&
+ ["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
+ );
+ const skipIntro = activeSegment?.skipSegment || (() => {});
+ const showSkipCreditButton = activeSegment?.type === "Outro";
+ const skipCredit = outroSkipper.skipSegment;
+ const hasContentAfterCredits =
+ outroSkipper.currentSegment && maxSeconds
+ ? outroSkipper.currentSegment.endTime < maxSeconds
+ : false;
+
+ // Get button text based on segment type
+ const skipButtonText = activeSegment
+ ? `Skip ${activeSegment.type}`
+ : "Skip Intro";
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
@@ -534,6 +608,7 @@ export const Controls: FC = ({
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
+ skipButtonText={skipButtonText}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts
new file mode 100644
index 00000000..395d1bd1
--- /dev/null
+++ b/hooks/useSegmentSkipper.ts
@@ -0,0 +1,114 @@
+import { useCallback, useEffect, useRef } from "react";
+import { MediaTimeSegment } from "@/providers/Downloads/types";
+import { useSettings } from "@/utils/atoms/settings";
+import { useHaptic } from "./useHaptic";
+
+type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
+
+interface UseSegmentSkipperProps {
+ segments: MediaTimeSegment[];
+ segmentType: SegmentType;
+ currentTime: number;
+ totalDuration?: number;
+ seek: (time: number) => void;
+ isPaused: boolean;
+}
+
+interface UseSegmentSkipperReturn {
+ currentSegment: MediaTimeSegment | null;
+ skipSegment: (notifyOrUseHaptics?: 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(false);
+
+ // Get skip mode based on segment type
+ const skipMode = (() => {
+ switch (segmentType) {
+ case "Intro":
+ return settings.skipIntro;
+ case "Outro":
+ return settings.skipOutro;
+ case "Recap":
+ return settings.skipRecap;
+ case "Commercial":
+ return settings.skipCommercial;
+ case "Preview":
+ return settings.skipPreview;
+ default:
+ return "none";
+ }
+ })();
+
+ // Memoize the seek wrapper to prevent cascading useEffect triggers
+ const wrappedSeek = useCallback(
+ (time: number) => {
+ seek(time);
+ },
+ [seek],
+ );
+
+ // Find current segment
+ const currentSegment =
+ segments.find(
+ (segment) =>
+ currentTime >= segment.startTime && currentTime < segment.endTime,
+ ) || null;
+
+ // Skip function with optional haptic feedback
+ const skipSegment = useCallback(
+ (notifyOrUseHaptics = true) => {
+ if (!currentSegment) return;
+
+ // For Outro segments, prevent seeking past the end
+ if (segmentType === "Outro" && totalDuration) {
+ const seekTime = Math.min(currentSegment.endTime, totalDuration);
+ wrappedSeek(seekTime);
+ } else {
+ wrappedSeek(currentSegment.endTime);
+ }
+
+ // Only trigger haptic feedback if explicitly requested (manual skip)
+ if (notifyOrUseHaptics) {
+ haptic();
+ }
+ },
+ [currentSegment, segmentType, totalDuration, wrappedSeek, haptic],
+ );
+
+ // Auto-skip logic when mode is 'auto'
+ useEffect(() => {
+ if (skipMode !== "auto" || isPaused) {
+ autoSkipTriggeredRef.current = false;
+ return;
+ }
+
+ if (currentSegment && !autoSkipTriggeredRef.current) {
+ autoSkipTriggeredRef.current = true;
+ skipSegment(false); // Don't trigger haptics for auto-skip
+ }
+
+ if (!currentSegment) {
+ autoSkipTriggeredRef.current = false;
+ }
+ }, [currentSegment, skipMode, isPaused, skipSegment]);
+
+ // Return null segment if skip mode is 'none'
+ return {
+ currentSegment: skipMode === "none" ? null : currentSegment,
+ skipSegment,
+ };
+};
diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts
index 976a6e23..5dffb48e 100644
--- a/providers/Downloads/types.ts
+++ b/providers/Downloads/types.ts
@@ -56,6 +56,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;
}
diff --git a/translations/en.json b/translations/en.json
index 3fe9efb6..81f8fdbb 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -308,6 +308,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": {
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..9b2cd856 100644
--- a/utils/segments.ts
+++ b/utils/segments.ts
@@ -74,10 +74,16 @@ export const getSegmentsForItem = (
): {
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
+ recapSegments: MediaTimeSegment[];
+ commercialSegments: MediaTimeSegment[];
+ previewSegments: MediaTimeSegment[];
} => {
return {
introSegments: item.introSegments || [],
creditSegments: item.creditSegments || [],
+ recapSegments: item.recapSegments || [],
+ commercialSegments: item.commercialSegments || [],
+ previewSegments: item.previewSegments || [],
};
};
@@ -95,6 +101,9 @@ const fetchMediaSegments = async (
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
+ recapSegments: MediaTimeSegment[];
+ commercialSegments: MediaTimeSegment[];
+ previewSegments: MediaTimeSegment[];
} | null> => {
try {
const response = await api.axiosInstance.get(
@@ -102,13 +111,22 @@ const fetchMediaSegments = async (
{
headers: getAuthHeaders(api),
params: {
- includeSegmentTypes: ["Intro", "Outro"],
+ includeSegmentTypes: [
+ "Intro",
+ "Outro",
+ "Recap",
+ "Commercial",
+ "Preview",
+ ],
},
},
);
const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = [];
+ const recapSegments: MediaTimeSegment[] = [];
+ const commercialSegments: MediaTimeSegment[] = [];
+ const previewSegments: MediaTimeSegment[] = [];
response.data.Items.forEach((segment) => {
const timeSegment: MediaTimeSegment = {
@@ -124,13 +142,27 @@ const fetchMediaSegments = async (
case "Outro":
creditSegments.push(timeSegment);
break;
- // Optionally handle other types like Recap, Commercial, Preview
+ case "Recap":
+ recapSegments.push(timeSegment);
+ break;
+ case "Commercial":
+ commercialSegments.push(timeSegment);
+ break;
+ case "Preview":
+ previewSegments.push(timeSegment);
+ break;
default:
break;
}
});
- return { introSegments, creditSegments };
+ return {
+ introSegments,
+ creditSegments,
+ recapSegments,
+ commercialSegments,
+ previewSegments,
+ };
} catch (_error) {
// Return null to indicate we should try legacy endpoints
return null;
@@ -146,6 +178,9 @@ const fetchLegacySegments = async (
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
+ recapSegments: MediaTimeSegment[];
+ commercialSegments: MediaTimeSegment[];
+ previewSegments: MediaTimeSegment[];
}> => {
const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = [];
@@ -184,7 +219,13 @@ const fetchLegacySegments = async (
console.error("Failed to fetch legacy segments", error);
}
- return { introSegments, creditSegments };
+ return {
+ introSegments,
+ creditSegments,
+ recapSegments: [],
+ commercialSegments: [],
+ previewSegments: [],
+ };
};
export const fetchAndParseSegments = async (
@@ -193,6 +234,9 @@ export const fetchAndParseSegments = async (
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
+ recapSegments: MediaTimeSegment[];
+ commercialSegments: MediaTimeSegment[];
+ previewSegments: MediaTimeSegment[];
}> => {
// Try new API first (Jellyfin 10.11+)
const newSegments = await fetchMediaSegments(itemId, api);