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..1c2ec8ab --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx @@ -0,0 +1,233 @@ +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"; + +/** + * 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"] | null, + 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(); + const navigation = useNavigation(); + + useEffect(() => { + navigation.setOptions({ + title: t("home.settings.other.segment_skip_settings"), + }); + }, [navigation, t]); + + const skipIntroOptions = useSkipOptions( + "skipIntro", + settings, + updateSettings, + t, + ); + const skipOutroOptions = useSkipOptions( + "skipOutro", + settings, + updateSettings, + t, + ); + const skipRecapOptions = useSkipOptions( + "skipRecap", + settings, + updateSettings, + t, + ); + const skipCommercialOptions = useSkipOptions( + "skipCommercial", + settings, + updateSettings, + t, + ); + const skipPreviewOptions = useSkipOptions( + "skipPreview", + settings, + 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_auto"), + value: "auto", + }, + { + label: t("home.settings.other.segment_skip_ask"), + value: "ask", + }, + { + label: t("home.settings.other.segment_skip_none"), + value: "none", + }, +]; 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..e209137e 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -19,7 +19,9 @@ interface BottomControlsProps { currentTime: number; remainingTime: number; showSkipButton: boolean; + skipButtonText: string; showSkipCreditButton: boolean; + skipCreditButtonText: string; hasContentAfterCredits: boolean; skipIntro: () => void; skipCredit: () => void; @@ -67,7 +69,9 @@ export const BottomControls: FC = ({ currentTime, remainingTime, showSkipButton, + skipButtonText, showSkipCreditButton, + skipCreditButtonText, hasContentAfterCredits, skipIntro, skipCredit, @@ -136,7 +140,7 @@ export const BottomControls: FC = ({ {/* Smart Skip Credits behavior: - Show "Skip Credits" if there's content after credits OR no next episode @@ -146,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 96dfad6b..674a6dfd 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,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, @@ -300,27 +323,122 @@ 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, - ); + // 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 + // 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 + playTimeoutRef.current = setTimeout(() => { + play(); + playTimeoutRef.current = null; + }, 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 = useMemo(() => { + 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; + }, [ + 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 || noop; + 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 using i18n + const { t } = useTranslation(); + const skipButtonText = activeSegment + ? t(`player.skip_${activeSegment.type.toLowerCase()}`) + : t("player.skip_intro"); + const skipCreditButtonText = t("player.skip_outro"); const goToItemCommon = useCallback( (item: BaseItemDto) => { @@ -534,7 +652,9 @@ export const Controls: FC = ({ currentTime={currentTime} remainingTime={remainingTime} showSkipButton={showSkipButton} + skipButtonText={skipButtonText} showSkipCreditButton={showSkipCreditButton} + skipCreditButtonText={skipCreditButtonText} hasContentAfterCredits={hasContentAfterCredits} skipIntro={skipIntro} skipCredit={skipCredit} diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts new file mode 100644 index 00000000..a3b54a5c --- /dev/null +++ b/hooks/useSegmentSkipper.ts @@ -0,0 +1,105 @@ +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"; + } + })(); + + // 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); + seek(seekTime); + } else { + seek(currentSegment.endTime); + } + + // Only trigger haptic feedback if explicitly requested (manual skip) + if (notifyOrUseHaptics) { + haptic(); + } + }, + [currentSegment, segmentType, totalDuration, seek, 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..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/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);