From cec2c4a712f03f187aed31b7bafe5c7513dac25a Mon Sep 17 00:00:00 2001 From: Gauvain Date: Wed, 27 May 2026 22:56:39 +0200 Subject: [PATCH] feat(player): add media segment skip with all 5 Jellyfin segment types Closes #1312 Fixes #883 Adds a unified segment skip feature using the Jellyfin 10.11+ MediaSegments API. Replaces the legacy intro-only and credits-only hooks with a single useSegmentSkipper hook covering Intro, Outro, Recap, Commercial, and Preview. Three modes per segment type: none, ask (show button), auto (skip automatically). A dedicated submenu under Playback Controls keeps the main settings page uncluttered. Highlights: - utils/segments.ts uses getMediaSegmentsApi from @jellyfin/sdk so includeSegmentTypes is serialized as repeated keys instead of the bracket-encoded form axios produces by default (the Jellyfin server silently ignored the filter otherwise). Falls back to the pre-10.11 intro-skipper / chapter-credits plugin endpoints when the new API is unavailable. - hooks/useSegmentSkipper.ts stores seek and haptic in refs so the auto-skip effect does not re-run when their identities change (useHaptic returns a fresh no-op every render when disabled). currentSegment is memoized; the per-segment-type setting lookup uses a small map instead of a switch IIFE. - components/video-player/controls/Controls.tsx prioritizes Commercial > Recap > Intro > Preview > Outro when multiple segments overlap and exposes the active type to BottomControls via skipButtonText. - components/video-player/controls/BottomControls.tsx accepts the dynamic skipButtonText/skipCreditButtonText props. - providers/Downloads/types.ts extends DownloadedItem with the three new segment buckets for offline playback. - utils/atoms/settings.ts adds SegmentSkipMode and the five skip settings, defaulting to "ask". - app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx renders the five dropdowns from a data table. - translations/en.json and translations/fr.json add the new keys. --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 65 ++++++ .../(home)/settings/segment-skip/page.tsx | 101 ++++++++ .../settings/PlaybackControlsSettings.tsx | 11 + components/tv/TVSkipSegmentCard.tsx | 22 +- .../video-player/controls/BottomControls.tsx | 39 ++-- components/video-player/controls/Controls.tsx | 194 ++++++++++++++-- .../video-player/controls/Controls.tv.tsx | 168 ++++++++++++-- hooks/useCreditSkipper.ts | 109 --------- hooks/useIntroSkipper.ts | 68 ------ hooks/useSegmentSkipper.ts | 109 +++++++++ providers/Downloads/types.ts | 18 +- translations/en.json | 19 ++ utils/atoms/settings.ts | 15 ++ utils/segments.ts | 218 ++++++++---------- 14 files changed, 774 insertions(+), 382 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx delete mode 100644 hooks/useCreditSkipper.ts delete mode 100644 hooks/useIntroSkipper.ts create mode 100644 hooks/useSegmentSkipper.ts diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index a9a2e2fb..ac590444 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -36,6 +36,7 @@ import { InactivityTimeout, type MpvCacheMode, type MpvVoDriver, + type SegmentSkipMode, TVTypographyScale, useSettings, } from "@/utils/atoms/settings"; @@ -47,6 +48,22 @@ import { } from "@/utils/secureCredentials"; import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache"; +const SEGMENT_SKIP_ROWS: { + key: + | "skipIntro" + | "skipOutro" + | "skipRecap" + | "skipCommercial" + | "skipPreview"; + 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" }, +]; + export default function SettingsTV() { const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -535,6 +552,30 @@ export default function SettingsTV() { ); }, [inactivityTimeoutOptions, t]); + // Segment skip: same auto/ask/none choice for every segment type. + const segmentSkipModeLabel = (mode: SegmentSkipMode) => + t(`home.settings.other.segment_skip_${mode}`); + + const buildSegmentSkipOptions = ( + current: SegmentSkipMode, + ): TVOptionItem[] => [ + { + label: t("home.settings.other.segment_skip_auto"), + value: "auto", + selected: current === "auto", + }, + { + label: t("home.settings.other.segment_skip_ask"), + value: "ask", + selected: current === "ask", + }, + { + label: t("home.settings.other.segment_skip_none"), + value: "none", + selected: current === "none", + }, + ]; + return ( @@ -819,6 +860,30 @@ export default function SettingsTV() { formatValue={(v) => `${v} MB`} /> + {/* Segment Skip Section */} + + {SEGMENT_SKIP_ROWS.map((row, index) => { + const current = (settings[row.key] ?? "ask") as SegmentSkipMode; + const rowLabel = t(`home.settings.other.${row.labelKey}`); + return ( + + showOptions({ + title: rowLabel, + options: buildSegmentSkipOptions(current), + onSelect: (value) => updateSettings({ [row.key]: value }), + }) + } + /> + ); + })} + {/* Appearance Section */} = [ + { 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 17ee5367..e85fb120 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(); @@ -251,6 +253,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/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx index 140fa317..83915d03 100644 --- a/components/tv/TVSkipSegmentCard.tsx +++ b/components/tv/TVSkipSegmentCard.tsx @@ -19,10 +19,27 @@ import { Text } from "@/components/common/Text"; import { scaleSize } from "@/utils/scaleSize"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; +export type TVSkipSegmentType = + | "intro" + | "credits" + | "outro" + | "recap" + | "commercial" + | "preview"; + +const SEGMENT_LABEL_KEY: Record = { + intro: "player.skip_intro", + credits: "player.skip_credits", + outro: "player.skip_outro", + recap: "player.skip_recap", + commercial: "player.skip_commercial", + preview: "player.skip_preview", +}; + export interface TVSkipSegmentCardProps { show: boolean; onPress: () => void; - type: "intro" | "credits"; + type: TVSkipSegmentType; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; /** Callback ref setter for focus guide destination pattern */ @@ -72,8 +89,7 @@ export const TVSkipSegmentCard: FC = ({ bottom: bottomPosition.value, })); - const labelText = - type === "intro" ? t("player.skip_intro") : t("player.skip_credits"); + const labelText = t(SEGMENT_LABEL_KEY[type]); if (!show) return null; diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 81c77ab8..f4e16c6a 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -34,11 +34,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; @@ -86,11 +88,13 @@ export const BottomControls: FC = ({ showRemoteBubble, currentTime, remainingTime, - showSkipButton, - showSkipCreditButton, + showSkipSegmentButton, + skipSegmentButtonText, + showSkipOutroButton, + skipOutroButtonText, hasContentAfterCredits, - skipIntro, - skipCredit, + onSkipSegment, + onSkipOutro, nextItem, handleNextEpisodeAutoPlay, handleNextEpisodeManual, @@ -181,19 +185,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 || @@ -204,7 +207,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 3651876a..cf186dff 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"; @@ -43,6 +51,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; @@ -111,6 +122,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, @@ -316,27 +345,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) => { @@ -570,11 +712,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/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index a85f6215..d5c59b46 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -38,9 +38,8 @@ import { import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; -import { useCreditSkipper } from "@/hooks/useCreditSkipper"; -import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import { useSegmentSkipper } from "@/hooks/useSegmentSkipper"; import { useTrickplay } from "@/hooks/useTrickplay"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; @@ -51,7 +50,14 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time"; +import { useSegments } from "@/utils/segments"; +import { + formatTimeString, + msToSeconds, + msToTicks, + secondsToMs, + ticksToMs, +} from "@/utils/time"; import { CONTROLS_CONSTANTS } from "./constants"; import { useVideoContext } from "./contexts/VideoContext"; import { useChapterNavigation } from "./hooks/useChapterNavigation"; @@ -99,6 +105,9 @@ interface Props { const TV_SEEKBAR_HEIGHT = 14; const TV_AUTO_HIDE_TIMEOUT = 5000; +// Stable no-op so the generic skip card keeps a constant onPress when idle. +const noop = () => {}; + // Trickplay bubble positioning constants const TV_TRICKPLAY_SCALE = 2; const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5; @@ -427,30 +436,139 @@ export const Controls: FC = ({ seek, }); - // Skip intro/credits hooks - // Note: hooks expect seek callback that takes ms, and seek prop already expects ms + // Segment skipping (intro + outro/credits) via the unified hook. const offline = useOfflineMode(); - const { showSkipButton, skipIntro } = useIntroSkipper( - item.Id!, - currentTime, - seek, - _play, + + const { data: segments } = useSegments( + item.Id ?? "", offline, - api, downloadedFiles, + api, ); - const { showSkipCreditButton, skipCredit, hasContentAfterCredits } = - useCreditSkipper( - item.Id!, - currentTime, - seek, - _play, - offline, - api, - downloadedFiles, - max.value, - ); + const currentTimeSeconds = msToSeconds(currentTime); + const maxSeconds = msToSeconds(maxMs); + + // useSegmentSkipper deals in seconds; the player seek expects ms. The 200ms + // delayed play() mirrors the mobile controls: some seeks otherwise resume + // from the pre-seek position. + const playSegmentTimeoutRef = useRef | null>( + null, + ); + useEffect(() => { + return () => { + if (playSegmentTimeoutRef.current) { + clearTimeout(playSegmentTimeoutRef.current); + } + }; + }, []); + + const seekSeconds = useCallback( + (timeInSeconds: number) => { + if (playSegmentTimeoutRef.current) { + clearTimeout(playSegmentTimeoutRef.current); + } + seek(secondsToMs(timeInSeconds)); + playSegmentTimeoutRef.current = setTimeout(() => { + _play(); + playSegmentTimeoutRef.current = null; + }, 200); + }, + [seek, _play], + ); + + const introSkipper = useSegmentSkipper({ + segments: segments?.introSegments ?? [], + segmentType: "Intro", + currentTime: currentTimeSeconds, + seek: seekSeconds, + isPaused: !isPlaying, + }); + + const outroSkipper = useSegmentSkipper({ + segments: segments?.creditSegments ?? [], + segmentType: "Outro", + currentTime: currentTimeSeconds, + totalDuration: maxSeconds, + seek: seekSeconds, + isPaused: !isPlaying, + }); + + const recapSkipper = useSegmentSkipper({ + segments: segments?.recapSegments ?? [], + segmentType: "Recap", + currentTime: currentTimeSeconds, + seek: seekSeconds, + isPaused: !isPlaying, + }); + + const commercialSkipper = useSegmentSkipper({ + segments: segments?.commercialSegments ?? [], + segmentType: "Commercial", + currentTime: currentTimeSeconds, + seek: seekSeconds, + isPaused: !isPlaying, + }); + + const previewSkipper = useSegmentSkipper({ + segments: segments?.previewSegments ?? [], + segmentType: "Preview", + currentTime: currentTimeSeconds, + seek: seekSeconds, + isPaused: !isPlaying, + }); + + // Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro. + // The outro keeps its dedicated card (it composes with the Next Episode + // countdown); the other four share one generic skip card. Including the outro + // here keeps the two cards mutually exclusive. + const activeSegment = useMemo(() => { + if (commercialSkipper.currentSegment) + return { + type: "commercial" as const, + skipSegment: commercialSkipper.skipSegment, + }; + if (recapSkipper.currentSegment) + return { type: "recap" as const, skipSegment: recapSkipper.skipSegment }; + if (introSkipper.currentSegment) + return { type: "intro" as const, skipSegment: introSkipper.skipSegment }; + if (previewSkipper.currentSegment) + return { + type: "preview" as const, + skipSegment: previewSkipper.skipSegment, + }; + if (outroSkipper.currentSegment) + return { type: "outro" as const, 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, + ]); + + const isOutroActive = activeSegment?.type === "outro"; + + // Generic card (intro/recap/commercial/preview). + const showSkipButton = !!activeSegment && !isOutroActive; + const skipActiveSegment = activeSegment?.skipSegment ?? noop; + const activeSegmentType = isOutroActive + ? "intro" + : (activeSegment?.type ?? "intro"); + + // Outro card (composes with the Next Episode countdown). + const showSkipCreditButton = isOutroActive; + const skipCredit = outroSkipper.skipSegment; + const hasContentAfterCredits = + outroSkipper.currentSegment && maxSeconds + ? outroSkipper.currentSegment.endTime < maxSeconds + : false; // Countdown logic const isCountdownActive = useMemo(() => { @@ -1126,11 +1244,11 @@ export const Controls: FC = ({ /> )} - {/* Skip intro card */} + {/* Generic skip card (intro / recap / commercial / preview) */} 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 3c4271b1..f2beb04c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -304,6 +304,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" }, "music": { @@ -629,6 +644,10 @@ "settings": "Settings", "skip_intro": "Skip intro", "skip_credits": "Skip credits", + "skip_outro": "Skip outro", + "skip_recap": "Skip recap", + "skip_commercial": "Skip commercial", + "skip_preview": "Skip preview", "stopPlayback": "Stop playback", "stopPlayingTitle": "Stop playing \"{{title}}\"?", "stopPlayingConfirm": "Are you sure you want to stop playback?", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index f4c6c7dd..8fd44276 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -183,6 +183,9 @@ export enum TVTypographyScale { ExtraLarge = "extraLarge", } +// 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 @@ -246,6 +249,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; @@ -349,6 +358,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); };