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);