From 62f50590d4301ac357ab2b79cdc21d5ea79f7af6 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 13:44:45 +0100 Subject: [PATCH 01/17] feat: add comprehensive segment skip with all 5 types and settings submenu - Add SegmentSkipMode type ('none', 'ask', 'auto') in settings.ts - Create 5 segment skip settings: skipIntro, skipOutro, skipRecap, skipCommercial, skipPreview - Update segments.ts to fetch all 5 segment types from Jellyfin MediaSegments API (10.11+) - Create unified useSegmentSkipper hook supporting all segment types with 3 modes - Update video player Controls.tsx with priority system (Commercial > Recap > Intro > Preview > Outro) - Add dynamic skip button text in BottomControls.tsx - Create dedicated settings submenu at settings/segment-skip/page.tsx - Simplify PlaybackControlsSettings.tsx with navigation to submenu - Extend DownloadedItem interface with all segment types for offline support - Add 13+ translation keys for segment skip UI --- .../(home)/settings/segment-skip/page.tsx | 246 ++++++++++++++++++ .../settings/PlaybackControlsSettings.tsx | 11 + .../video-player/controls/BottomControls.tsx | 4 +- components/video-player/controls/Controls.tsx | 113 ++++++-- hooks/useSegmentSkipper.ts | 114 ++++++++ providers/Downloads/types.ts | 6 + translations/en.json | 15 ++ utils/atoms/settings.ts | 15 ++ utils/segments.ts | 52 +++- 9 files changed, 552 insertions(+), 24 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx create mode 100644 hooks/useSegmentSkipper.ts 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); From 91de36c3bd08d7c38eb64a7c8f08e77ec3ed81bb Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 14:04:27 +0100 Subject: [PATCH 02/17] refactor: apply CodeRabbit suggestions for segment skip feature - Add missing segment types (recap, commercial, preview) to JobStatus - Consolidate duplicate useMemo blocks with factory function - Improve code maintainability and consistency --- .../(home)/settings/segment-skip/page.tsx | 125 ++++++++---------- providers/Downloads/types.ts | 6 + 2 files changed, 62 insertions(+), 69 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx index 9a924f87..bf1a1556 100644 --- a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx @@ -11,6 +11,37 @@ import { PlatformDropdown } from "@/components/PlatformDropdown"; import DisabledSetting from "@/components/settings/DisabledSetting"; import { useSettings } from "@/utils/atoms/settings"; +/** + * Factory function to create skip options for a specific segment type + * Reduces code duplication across all 5 segment types + */ +const useSkipOptions = ( + settingKey: + | "skipIntro" + | "skipOutro" + | "skipRecap" + | "skipCommercial" + | "skipPreview", + settings: ReturnType["settings"], + updateSettings: ReturnType["updateSettings"], + t: TFunction<"translation", undefined>, +) => { + return useMemo( + () => [ + { + options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({ + type: "radio" as const, + label: option.label, + value: option.value, + selected: option.value === settings[settingKey], + onPress: () => updateSettings({ [settingKey]: option.value }), + })), + }, + ], + [settings[settingKey], updateSettings, t, settingKey], + ); +}; + export default function SegmentSkipPage() { const { settings, updateSettings, pluginSettings } = useSettings(); const { t } = useTranslation(); @@ -22,79 +53,35 @@ export default function SegmentSkipPage() { }); }, [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 skipIntroOptions = useSkipOptions( + "skipIntro", + settings, + 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 skipOutroOptions = useSkipOptions( + "skipOutro", + settings, + 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 skipRecapOptions = useSkipOptions( + "skipRecap", + settings, + 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 skipCommercialOptions = useSkipOptions( + "skipCommercial", + settings, + 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], + const skipPreviewOptions = useSkipOptions( + "skipPreview", + settings, + updateSettings, + t, ); if (!settings) return null; diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts index 5dffb48e..333b1f1c 100644 --- a/providers/Downloads/types.ts +++ b/providers/Downloads/types.ts @@ -150,6 +150,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 */ From be575b7c04fe2d595c15313b22b4d94d326c230e Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 14:07:14 +0100 Subject: [PATCH 03/17] refactor: address GitHub Copilot review comments - Remove unnecessary currentSegment from skipSegment dependency array - Remove redundant wrappedSeek wrapper (ref guard prevents issues) - Document 200ms setTimeout delay for seek operations - Improve code clarity and reduce unnecessary re-renders --- components/video-player/controls/Controls.tsx | 3 +++ hooks/useSegmentSkipper.ts | 14 +++----------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 4897bd8a..8ecf1844 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -313,9 +313,12 @@ export const Controls: FC = ({ const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined; // Wrapper to convert segment skip from seconds to milliseconds + // Includes 200ms delay to allow seek operation to complete before resuming playback const seekMs = useCallback( (timeInSeconds: number) => { seek(timeInSeconds * 1000); + // Brief delay ensures the seek operation completes before resuming playback + // Without this, playback may resume from the old position setTimeout(() => { play(); }, 200); diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts index 395d1bd1..f937b8e6 100644 --- a/hooks/useSegmentSkipper.ts +++ b/hooks/useSegmentSkipper.ts @@ -53,14 +53,6 @@ export const useSegmentSkipper = ({ } })(); - // Memoize the seek wrapper to prevent cascading useEffect triggers - const wrappedSeek = useCallback( - (time: number) => { - seek(time); - }, - [seek], - ); - // Find current segment const currentSegment = segments.find( @@ -76,9 +68,9 @@ export const useSegmentSkipper = ({ // For Outro segments, prevent seeking past the end if (segmentType === "Outro" && totalDuration) { const seekTime = Math.min(currentSegment.endTime, totalDuration); - wrappedSeek(seekTime); + seek(seekTime); } else { - wrappedSeek(currentSegment.endTime); + seek(currentSegment.endTime); } // Only trigger haptic feedback if explicitly requested (manual skip) @@ -86,7 +78,7 @@ export const useSegmentSkipper = ({ haptic(); } }, - [currentSegment, segmentType, totalDuration, wrappedSeek, haptic], + [segmentType, totalDuration, seek, haptic], ); // Auto-skip logic when mode is 'auto' From 96f6ad000ba61abed8b74f1b7e812a8e44c11c58 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 14:10:28 +0100 Subject: [PATCH 04/17] feat: add i18n support for skip button text - Add player.skip_* translation keys for all 5 segment types - Enable proper localization of skip button text - Addresses GitHub Copilot review comment --- components/video-player/controls/Controls.tsx | 8 +++++--- translations/en.json | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 8ecf1844..aa2e1a84 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -5,6 +5,7 @@ import type { } from "@jellyfin/sdk/lib/generated-client"; import { useLocalSearchParams } from "expo-router"; import { type FC, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { StyleSheet, useWindowDimensions, View } from "react-native"; import Animated, { Easing, @@ -394,10 +395,11 @@ export const Controls: FC = ({ ? outroSkipper.currentSegment.endTime < maxSeconds : false; - // Get button text based on segment type + // Get button text based on segment type using i18n + const { t } = useTranslation(); const skipButtonText = activeSegment - ? `Skip ${activeSegment.type}` - : "Skip Intro"; + ? t(`player.skip_${activeSegment.type.toLowerCase()}`) + : t("player.skip_intro"); const goToItemCommon = useCallback( (item: BaseItemDto) => { diff --git a/translations/en.json b/translations/en.json index 81f8fdbb..a6d4bdab 100644 --- a/translations/en.json +++ b/translations/en.json @@ -24,6 +24,13 @@ "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" + }, "server": { "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", "server_url_placeholder": "http(s)://your-server.com", From d3bc2ac5d548987a40a111f957a54f518940535a Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 14:12:36 +0100 Subject: [PATCH 05/17] refactor: move player translations to common section Relocates player-specific translation keys from the "player" namespace to the "common" namespace to improve reusability across different components. All player-related strings (error messages, playback controls, download prompts) are now accessible as common translations, enabling their use throughout the application without namespace-specific imports. --- translations/en.json | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/translations/en.json b/translations/en.json index a6d4bdab..5b99e6df 100644 --- a/translations/en.json +++ b/translations/en.json @@ -29,7 +29,25 @@ "skip_outro": "Skip Outro", "skip_recap": "Skip Recap", "skip_commercial": "Skip Commercial", - "skip_preview": "Skip Preview" + "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", @@ -612,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", From 97607b226343590fc3d817ce25d216ec031644f9 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 13:44:45 +0100 Subject: [PATCH 06/17] feat: add comprehensive segment skip with all 5 types and settings submenu - Add SegmentSkipMode type ('none', 'ask', 'auto') in settings.ts - Create 5 segment skip settings: skipIntro, skipOutro, skipRecap, skipCommercial, skipPreview - Update segments.ts to fetch all 5 segment types from Jellyfin MediaSegments API (10.11+) - Create unified useSegmentSkipper hook supporting all segment types with 3 modes - Update video player Controls.tsx with priority system (Commercial > Recap > Intro > Preview > Outro) - Add dynamic skip button text in BottomControls.tsx - Create dedicated settings submenu at settings/segment-skip/page.tsx - Simplify PlaybackControlsSettings.tsx with navigation to submenu - Extend DownloadedItem interface with all segment types for offline support - Add 13+ translation keys for segment skip UI --- .../(home)/settings/segment-skip/page.tsx | 246 ++++++++++++++++++ .../settings/PlaybackControlsSettings.tsx | 11 + .../video-player/controls/BottomControls.tsx | 4 +- components/video-player/controls/Controls.tsx | 113 ++++++-- hooks/useSegmentSkipper.ts | 114 ++++++++ providers/Downloads/types.ts | 6 + translations/en.json | 15 ++ utils/atoms/settings.ts | 15 ++ utils/segments.ts | 52 +++- 9 files changed, 552 insertions(+), 24 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx create mode 100644 hooks/useSegmentSkipper.ts 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); From feb5a41cff59de6d10ba07c44fb08a5d3e3adf39 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 14:04:27 +0100 Subject: [PATCH 07/17] refactor: apply CodeRabbit suggestions for segment skip feature - Add missing segment types (recap, commercial, preview) to JobStatus - Consolidate duplicate useMemo blocks with factory function - Improve code maintainability and consistency --- .../(home)/settings/segment-skip/page.tsx | 125 ++++++++---------- providers/Downloads/types.ts | 6 + 2 files changed, 62 insertions(+), 69 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx index 9a924f87..bf1a1556 100644 --- a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx @@ -11,6 +11,37 @@ import { PlatformDropdown } from "@/components/PlatformDropdown"; import DisabledSetting from "@/components/settings/DisabledSetting"; import { useSettings } from "@/utils/atoms/settings"; +/** + * Factory function to create skip options for a specific segment type + * Reduces code duplication across all 5 segment types + */ +const useSkipOptions = ( + settingKey: + | "skipIntro" + | "skipOutro" + | "skipRecap" + | "skipCommercial" + | "skipPreview", + settings: ReturnType["settings"], + updateSettings: ReturnType["updateSettings"], + t: TFunction<"translation", undefined>, +) => { + return useMemo( + () => [ + { + options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({ + type: "radio" as const, + label: option.label, + value: option.value, + selected: option.value === settings[settingKey], + onPress: () => updateSettings({ [settingKey]: option.value }), + })), + }, + ], + [settings[settingKey], updateSettings, t, settingKey], + ); +}; + export default function SegmentSkipPage() { const { settings, updateSettings, pluginSettings } = useSettings(); const { t } = useTranslation(); @@ -22,79 +53,35 @@ export default function SegmentSkipPage() { }); }, [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 skipIntroOptions = useSkipOptions( + "skipIntro", + settings, + 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 skipOutroOptions = useSkipOptions( + "skipOutro", + settings, + 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 skipRecapOptions = useSkipOptions( + "skipRecap", + settings, + 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 skipCommercialOptions = useSkipOptions( + "skipCommercial", + settings, + 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], + const skipPreviewOptions = useSkipOptions( + "skipPreview", + settings, + updateSettings, + t, ); if (!settings) return null; diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts index 5dffb48e..333b1f1c 100644 --- a/providers/Downloads/types.ts +++ b/providers/Downloads/types.ts @@ -150,6 +150,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 */ From 92460cf202de9c87b226ac70fafb018f420f2046 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 14:07:14 +0100 Subject: [PATCH 08/17] refactor: address GitHub Copilot review comments - Remove unnecessary currentSegment from skipSegment dependency array - Remove redundant wrappedSeek wrapper (ref guard prevents issues) - Document 200ms setTimeout delay for seek operations - Improve code clarity and reduce unnecessary re-renders --- components/video-player/controls/Controls.tsx | 3 +++ hooks/useSegmentSkipper.ts | 14 +++----------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 4897bd8a..8ecf1844 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -313,9 +313,12 @@ export const Controls: FC = ({ const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined; // Wrapper to convert segment skip from seconds to milliseconds + // Includes 200ms delay to allow seek operation to complete before resuming playback const seekMs = useCallback( (timeInSeconds: number) => { seek(timeInSeconds * 1000); + // Brief delay ensures the seek operation completes before resuming playback + // Without this, playback may resume from the old position setTimeout(() => { play(); }, 200); diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts index 395d1bd1..f937b8e6 100644 --- a/hooks/useSegmentSkipper.ts +++ b/hooks/useSegmentSkipper.ts @@ -53,14 +53,6 @@ export const useSegmentSkipper = ({ } })(); - // Memoize the seek wrapper to prevent cascading useEffect triggers - const wrappedSeek = useCallback( - (time: number) => { - seek(time); - }, - [seek], - ); - // Find current segment const currentSegment = segments.find( @@ -76,9 +68,9 @@ export const useSegmentSkipper = ({ // For Outro segments, prevent seeking past the end if (segmentType === "Outro" && totalDuration) { const seekTime = Math.min(currentSegment.endTime, totalDuration); - wrappedSeek(seekTime); + seek(seekTime); } else { - wrappedSeek(currentSegment.endTime); + seek(currentSegment.endTime); } // Only trigger haptic feedback if explicitly requested (manual skip) @@ -86,7 +78,7 @@ export const useSegmentSkipper = ({ haptic(); } }, - [currentSegment, segmentType, totalDuration, wrappedSeek, haptic], + [segmentType, totalDuration, seek, haptic], ); // Auto-skip logic when mode is 'auto' From 378288bf082ecaa7e46bd186bb91acf281dc47a4 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 14:10:28 +0100 Subject: [PATCH 09/17] feat: add i18n support for skip button text - Add player.skip_* translation keys for all 5 segment types - Enable proper localization of skip button text - Addresses GitHub Copilot review comment --- components/video-player/controls/Controls.tsx | 8 +++++--- translations/en.json | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 8ecf1844..aa2e1a84 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -5,6 +5,7 @@ import type { } from "@jellyfin/sdk/lib/generated-client"; import { useLocalSearchParams } from "expo-router"; import { type FC, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { StyleSheet, useWindowDimensions, View } from "react-native"; import Animated, { Easing, @@ -394,10 +395,11 @@ export const Controls: FC = ({ ? outroSkipper.currentSegment.endTime < maxSeconds : false; - // Get button text based on segment type + // Get button text based on segment type using i18n + const { t } = useTranslation(); const skipButtonText = activeSegment - ? `Skip ${activeSegment.type}` - : "Skip Intro"; + ? t(`player.skip_${activeSegment.type.toLowerCase()}`) + : t("player.skip_intro"); const goToItemCommon = useCallback( (item: BaseItemDto) => { diff --git a/translations/en.json b/translations/en.json index 81f8fdbb..a6d4bdab 100644 --- a/translations/en.json +++ b/translations/en.json @@ -24,6 +24,13 @@ "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" + }, "server": { "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", "server_url_placeholder": "http(s)://your-server.com", From ebf6e31478de33dd3b8e0ead03ee21733489b136 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 14:12:36 +0100 Subject: [PATCH 10/17] refactor: move player translations to common section Relocates player-specific translation keys from the "player" namespace to the "common" namespace to improve reusability across different components. All player-related strings (error messages, playback controls, download prompts) are now accessible as common translations, enabling their use throughout the application without namespace-specific imports. --- translations/en.json | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/translations/en.json b/translations/en.json index a6d4bdab..5b99e6df 100644 --- a/translations/en.json +++ b/translations/en.json @@ -29,7 +29,25 @@ "skip_outro": "Skip Outro", "skip_recap": "Skip Recap", "skip_commercial": "Skip Commercial", - "skip_preview": "Skip Preview" + "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", @@ -612,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", From e9bb6b3c4053f44dcdc1caf380791f9c6c0edbf9 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 20:11:28 +0100 Subject: [PATCH 11/17] fix: correct order of segment skip options in settings --- app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx index bf1a1556..71ce8e51 100644 --- a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx @@ -219,15 +219,15 @@ const SEGMENT_SKIP_OPTIONS = ( value: "none" | "ask" | "auto"; }> => [ { - label: t("home.settings.other.segment_skip_none"), - value: "none", + 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_auto"), - value: "auto", + label: t("home.settings.other.segment_skip_none"), + value: "none", }, ]; From 294b3f19c309bc377217875640e3caf5121c46ec Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 20:13:49 +0100 Subject: [PATCH 12/17] feat: add timeout management for playback to prevent race conditions --- components/video-player/controls/Controls.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index aa2e1a84..278343fe 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -4,7 +4,7 @@ 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, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, useWindowDimensions, View } from "react-native"; import Animated, { @@ -111,6 +111,18 @@ 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); + + // Clean up timeout on unmount + useEffect(() => { + return () => { + if (playTimeoutRef.current) { + clearTimeout(playTimeoutRef.current); + } + }; + }, []); + const { height: screenHeight, width: screenWidth } = useWindowDimensions(); const { previousItem, nextItem } = usePlaybackManager({ item, @@ -317,11 +329,16 @@ export const Controls: FC = ({ // Includes 200ms delay to allow seek operation to complete before resuming playback const seekMs = useCallback( (timeInSeconds: number) => { + // Cancel any pending play call to avoid race conditions + if (playTimeoutRef.current) { + clearTimeout(playTimeoutRef.current); + } seek(timeInSeconds * 1000); // Brief delay ensures the seek operation completes before resuming playback // Without this, playback may resume from the old position - setTimeout(() => { + playTimeoutRef.current = setTimeout(() => { play(); + playTimeoutRef.current = null; }, 200); }, [seek, play], From c3271859b8e0e3743d8b51278e9570746714081c Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 20:15:05 +0100 Subject: [PATCH 13/17] fix: handle null settings in useSkipOptions for safer access --- app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx index 71ce8e51..1c2ec8ab 100644 --- a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx @@ -22,7 +22,7 @@ const useSkipOptions = ( | "skipRecap" | "skipCommercial" | "skipPreview", - settings: ReturnType["settings"], + settings: ReturnType["settings"] | null, updateSettings: ReturnType["updateSettings"], t: TFunction<"translation", undefined>, ) => { @@ -33,12 +33,12 @@ const useSkipOptions = ( type: "radio" as const, label: option.label, value: option.value, - selected: option.value === settings[settingKey], + selected: option.value === settings?.[settingKey], onPress: () => updateSettings({ [settingKey]: option.value }), })), }, ], - [settings[settingKey], updateSettings, t, settingKey], + [settings?.[settingKey], updateSettings, t, settingKey], ); }; From fe315699b9636f49fc1c2eb0717ec62030dfa266 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 20:15:11 +0100 Subject: [PATCH 14/17] fix: update dependencies in skipSegment callback for accurate state tracking --- hooks/useSegmentSkipper.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts index f937b8e6..a3b54a5c 100644 --- a/hooks/useSegmentSkipper.ts +++ b/hooks/useSegmentSkipper.ts @@ -78,9 +78,8 @@ export const useSegmentSkipper = ({ haptic(); } }, - [segmentType, totalDuration, seek, haptic], + [currentSegment, segmentType, totalDuration, seek, haptic], ); - // Auto-skip logic when mode is 'auto' useEffect(() => { if (skipMode !== "auto" || isPaused) { From 6c3fa704dbf854d031b6d2146400883a545e05cb Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 20:16:10 +0100 Subject: [PATCH 15/17] refactor: remove unused Segment interface from MediaTimeSegment --- providers/Downloads/types.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts index 333b1f1c..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. */ From a27ea154baa378d99bb82c1ddf82baf9663f3d11 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 20:18:09 +0100 Subject: [PATCH 16/17] feat: add skip credit button text localization to BottomControls and Controls --- components/video-player/controls/BottomControls.tsx | 4 +++- components/video-player/controls/Controls.tsx | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 46749009..e209137e 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -21,6 +21,7 @@ interface BottomControlsProps { showSkipButton: boolean; skipButtonText: string; showSkipCreditButton: boolean; + skipCreditButtonText: string; hasContentAfterCredits: boolean; skipIntro: () => void; skipCredit: () => void; @@ -70,6 +71,7 @@ export const BottomControls: FC = ({ showSkipButton, skipButtonText, showSkipCreditButton, + skipCreditButtonText, hasContentAfterCredits, skipIntro, skipCredit, @@ -148,7 +150,7 @@ export const BottomControls: FC = ({ showSkipCreditButton && (hasContentAfterCredits || !nextItem) } onPress={skipCredit} - buttonText='Skip Credits' + buttonText={skipCreditButtonText} /> {settings.autoPlayNextEpisode !== false && (settings.maxAutoPlayEpisodeCount.value === -1 || diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 278343fe..ef3788c1 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -417,6 +417,7 @@ export const Controls: FC = ({ const skipButtonText = activeSegment ? t(`player.skip_${activeSegment.type.toLowerCase()}`) : t("player.skip_intro"); + const skipCreditButtonText = t("player.skip_outro"); const goToItemCommon = useCallback( (item: BaseItemDto) => { @@ -632,6 +633,7 @@ export const Controls: FC = ({ showSkipButton={showSkipButton} skipButtonText={skipButtonText} showSkipCreditButton={showSkipCreditButton} + skipCreditButtonText={skipCreditButtonText} hasContentAfterCredits={hasContentAfterCredits} skipIntro={skipIntro} skipCredit={skipCredit} From 58f8015e3b3b50a91bd2d8e25bceb3d81b2b0232 Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 14 Jan 2026 20:21:15 +0100 Subject: [PATCH 17/17] refactor: optimize segment handling with useMemo and improve skip function fallback --- components/video-player/controls/Controls.tsx | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index ef3788c1..674a6dfd 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -4,7 +4,14 @@ import type { MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; import { useLocalSearchParams } from "expo-router"; -import { type FC, useCallback, useEffect, useRef, 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, { @@ -43,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; @@ -315,7 +325,7 @@ export const Controls: FC = ({ // Fetch all segments for the current item const { data: segments } = useSegments( - item.Id!, + item.Id ?? "", offline, downloadedFiles, api, @@ -388,7 +398,7 @@ export const Controls: FC = ({ // Determine which segment button to show (priority order) // Commercial > Recap > Intro > Preview > Outro - const activeSegment = (() => { + const activeSegment = useMemo(() => { if (commercialSkipper.currentSegment) return { type: "Commercial", ...commercialSkipper }; if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper }; @@ -397,14 +407,25 @@ export const Controls: FC = ({ return { type: "Preview", ...previewSkipper }; if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper }; return null; - })(); + }, [ + commercialSkipper.currentSegment, + recapSkipper.currentSegment, + introSkipper.currentSegment, + previewSkipper.currentSegment, + outroSkipper.currentSegment, + commercialSkipper, + recapSkipper, + introSkipper, + previewSkipper, + outroSkipper, + ]); // Legacy compatibility: map to old variable names const showSkipButton = !!( activeSegment && ["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type) ); - const skipIntro = activeSegment?.skipSegment || (() => {}); + const skipIntro = activeSegment?.skipSegment || noop; const showSkipCreditButton = activeSegment?.type === "Outro"; const skipCredit = outroSkipper.skipSegment; const hasContentAfterCredits =