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