mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 09:38:25 +01:00
Compare commits
1 Commits
autoskip
...
renovate/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b38478b44 |
@@ -1,101 +0,0 @@
|
|||||||
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 (
|
|
||||||
<View className='px-4'>
|
|
||||||
<ListGroup>
|
|
||||||
{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 (
|
|
||||||
<ListItem
|
|
||||||
key={key}
|
|
||||||
title={t(`home.settings.other.${labelKey}`)}
|
|
||||||
subtitle={t(`home.settings.other.${labelKey}_description`)}
|
|
||||||
disabled={locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={groups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${current}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t(`home.settings.other.${labelKey}`)}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -250,15 +248,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{/* Media Segment Skip Settings */}
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.segment_skip_settings")}
|
|
||||||
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
|
||||||
onPress={() => router.push("/settings/segment-skip/page")}
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,13 +18,11 @@ interface BottomControlsProps {
|
|||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipSegmentButton: boolean;
|
showSkipButton: boolean;
|
||||||
skipSegmentButtonText: string;
|
showSkipCreditButton: boolean;
|
||||||
showSkipOutroButton: boolean;
|
|
||||||
skipOutroButtonText: string;
|
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
onSkipSegment: () => void;
|
skipIntro: () => void;
|
||||||
onSkipOutro: () => void;
|
skipCredit: () => void;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
handleNextEpisodeAutoPlay: () => void;
|
handleNextEpisodeAutoPlay: () => void;
|
||||||
handleNextEpisodeManual: () => void;
|
handleNextEpisodeManual: () => void;
|
||||||
@@ -68,13 +66,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipSegmentButton,
|
showSkipButton,
|
||||||
skipSegmentButtonText,
|
showSkipCreditButton,
|
||||||
showSkipOutroButton,
|
|
||||||
skipOutroButtonText,
|
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
onSkipSegment,
|
skipIntro,
|
||||||
onSkipOutro,
|
skipCredit,
|
||||||
nextItem,
|
nextItem,
|
||||||
handleNextEpisodeAutoPlay,
|
handleNextEpisodeAutoPlay,
|
||||||
handleNextEpisodeManual,
|
handleNextEpisodeManual,
|
||||||
@@ -138,18 +134,19 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row space-x-2 shrink-0'>
|
<View className='flex flex-row space-x-2 shrink-0'>
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipSegmentButton}
|
showButton={showSkipButton}
|
||||||
onPress={onSkipSegment}
|
onPress={skipIntro}
|
||||||
buttonText={skipSegmentButtonText}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Outro button defers to "Next Episode" when credits run to the
|
{/* Smart Skip Credits behavior:
|
||||||
video end and a next episode exists. */}
|
- 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 */}
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={
|
showButton={
|
||||||
showSkipOutroButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={onSkipOutro}
|
onPress={skipCredit}
|
||||||
buttonText={skipOutroButtonText}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
@@ -160,7 +157,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
!nextItem
|
!nextItem
|
||||||
? false
|
? false
|
||||||
: // Show during credits if no content after, OR near end of video
|
: // Show during credits if no content after, OR near end of video
|
||||||
(showSkipOutroButton && !hasContentAfterCredits) ||
|
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||||
remainingTime < 10000
|
remainingTime < 10000
|
||||||
}
|
}
|
||||||
onFinish={handleNextEpisodeAutoPlay}
|
onFinish={handleNextEpisodeAutoPlay}
|
||||||
|
|||||||
@@ -4,15 +4,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import {
|
import { type FC, useCallback, useEffect, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -24,17 +16,17 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
@@ -50,9 +42,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
|||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||||
|
|
||||||
// No-op function to avoid creating new references on every render
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -121,24 +110,6 @@ export const Controls: FC<Props> = ({
|
|||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||||
|
|
||||||
// Ref to track pending play timeout for cleanup and cancellation
|
|
||||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||||
const { previousItem, nextItem } = usePlaybackManager({
|
const { previousItem, nextItem } = usePlaybackManager({
|
||||||
item,
|
item,
|
||||||
@@ -329,140 +300,27 @@ export const Controls: FC<Props> = ({
|
|||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Fetch all segments for the current item
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
const { data: segments } = useSegments(
|
item.Id!,
|
||||||
item.Id ?? "",
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
offline,
|
offline,
|
||||||
downloadedFiles,
|
|
||||||
api,
|
api,
|
||||||
|
downloadedFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTimeSeconds = msToSeconds(currentTime);
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
useCreditSkipper(
|
||||||
|
item.Id!,
|
||||||
// Segment hook deals in seconds; player API in ms. The 200ms delayed play()
|
currentTime,
|
||||||
// is a workaround: some seeks otherwise resume from the pre-seek position.
|
seek,
|
||||||
const seekMs = useCallback(
|
play,
|
||||||
(timeInSeconds: number) => {
|
offline,
|
||||||
if (playTimeoutRef.current) {
|
api,
|
||||||
clearTimeout(playTimeoutRef.current);
|
downloadedFiles,
|
||||||
}
|
maxMs,
|
||||||
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(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
@@ -675,13 +533,11 @@ export const Controls: FC<Props> = ({
|
|||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipSegmentButton={showSkipSegmentButton}
|
showSkipButton={showSkipButton}
|
||||||
skipSegmentButtonText={skipSegmentButtonText}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
showSkipOutroButton={showSkipOutroButton}
|
|
||||||
skipOutroButtonText={skipOutroButtonText}
|
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
onSkipSegment={onSkipSegment}
|
skipIntro={skipIntro}
|
||||||
onSkipOutro={onSkipOutro}
|
skipCredit={skipCredit}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||||
|
|||||||
109
hooks/useCreditSkipper.ts
Normal file
109
hooks/useCreditSkipper.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
68
hooks/useIntroSkipper.ts
Normal file
68
hooks/useIntroSkipper.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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<string | null>(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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -34,13 +34,13 @@
|
|||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^11.4.1",
|
||||||
"@react-navigation/material-top-tabs": "7.4.9",
|
"@react-navigation/material-top-tabs": "7.4.9",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.90.20",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||||
export interface DownloadedItem {
|
export interface DownloadedItem {
|
||||||
/** The Jellyfin item DTO. */
|
/** The Jellyfin item DTO. */
|
||||||
@@ -50,12 +56,6 @@ export interface DownloadedItem {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** The credit segments for the item. */
|
/** The credit segments for the item. */
|
||||||
creditSegments?: MediaTimeSegment[];
|
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. */
|
/** The user data for the item. */
|
||||||
userData: UserData;
|
userData: UserData;
|
||||||
}
|
}
|
||||||
@@ -144,12 +144,6 @@ export type JobStatus = {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||||
creditSegments?: MediaTimeSegment[];
|
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 */
|
/** The audio stream index selected for this download */
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
/** The subtitle stream index selected for this download */
|
/** The subtitle stream index selected for this download */
|
||||||
|
|||||||
@@ -24,31 +24,6 @@
|
|||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"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": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
"server_url_placeholder": "http(s)://your-server.com",
|
"server_url_placeholder": "http(s)://your-server.com",
|
||||||
@@ -333,21 +308,6 @@
|
|||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
"auto_play_next_episode": "Auto-play Next Episode",
|
"auto_play_next_episode": "Auto-play Next Episode",
|
||||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
"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"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
@@ -630,6 +590,26 @@
|
|||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No 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": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
"no_items_to_display": "No Items to Display",
|
"no_items_to_display": "No Items to Display",
|
||||||
|
|||||||
@@ -308,22 +308,7 @@
|
|||||||
"default_playback_speed": "Vitesse de lecture par défaut",
|
"default_playback_speed": "Vitesse de lecture par défaut",
|
||||||
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
||||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
"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": {
|
||||||
"downloads_title": "Téléchargements"
|
"downloads_title": "Téléchargements"
|
||||||
@@ -606,11 +591,6 @@
|
|||||||
"no_links": "Aucuns liens"
|
"no_links": "Aucuns liens"
|
||||||
},
|
},
|
||||||
"player": {
|
"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",
|
"error": "Erreur",
|
||||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -134,9 +134,6 @@ export enum VideoPlayer {
|
|||||||
MPV = 0,
|
MPV = 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment skip behavior options
|
|
||||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
|
||||||
|
|
||||||
// Audio transcoding mode - controls how surround audio is handled
|
// Audio transcoding mode - controls how surround audio is handled
|
||||||
// This controls server-side transcoding behavior for audio streams.
|
// This controls server-side transcoding behavior for audio streams.
|
||||||
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
||||||
@@ -184,12 +181,6 @@ export type Settings = {
|
|||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
autoPlayNextEpisode: boolean;
|
autoPlayNextEpisode: boolean;
|
||||||
// Media segment skip preferences
|
|
||||||
skipIntro: SegmentSkipMode;
|
|
||||||
skipOutro: SegmentSkipMode;
|
|
||||||
skipRecap: SegmentSkipMode;
|
|
||||||
skipCommercial: SegmentSkipMode;
|
|
||||||
skipPreview: SegmentSkipMode;
|
|
||||||
// Playback speed settings
|
// Playback speed settings
|
||||||
defaultPlaybackSpeed: number;
|
defaultPlaybackSpeed: number;
|
||||||
playbackSpeedPerMedia: Record<string, number>;
|
playbackSpeedPerMedia: Record<string, number>;
|
||||||
@@ -275,12 +266,6 @@ export const defaultValues: Settings = {
|
|||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
autoPlayNextEpisode: true,
|
autoPlayNextEpisode: true,
|
||||||
// Media segment skip defaults
|
|
||||||
skipIntro: "ask",
|
|
||||||
skipOutro: "ask",
|
|
||||||
skipRecap: "ask",
|
|
||||||
skipCommercial: "ask",
|
|
||||||
skipPreview: "ask",
|
|
||||||
// Playback speed defaults
|
// Playback speed defaults
|
||||||
defaultPlaybackSpeed: 1.0,
|
defaultPlaybackSpeed: 1.0,
|
||||||
playbackSpeedPerMedia: {},
|
playbackSpeedPerMedia: {},
|
||||||
|
|||||||
@@ -1,40 +1,46 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
||||||
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
||||||
|
|
||||||
export interface SegmentBuckets {
|
// New Jellyfin 10.11+ Media Segments API types
|
||||||
introSegments: MediaTimeSegment[];
|
interface MediaSegmentDto {
|
||||||
creditSegments: MediaTimeSegment[];
|
Id: string;
|
||||||
recapSegments: MediaTimeSegment[];
|
ItemId: string;
|
||||||
commercialSegments: MediaTimeSegment[];
|
Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||||
previewSegments: MediaTimeSegment[];
|
StartTicks: number;
|
||||||
|
EndTicks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
|
interface MediaSegmentsResponse {
|
||||||
|
Items: MediaSegmentDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy API types (for fallback)
|
||||||
interface IntroTimestamps {
|
interface IntroTimestamps {
|
||||||
IntroStart: number;
|
EpisodeId: string;
|
||||||
|
HideSkipPromptAt: number;
|
||||||
IntroEnd: number;
|
IntroEnd: number;
|
||||||
|
IntroStart: number;
|
||||||
|
ShowSkipPromptAt: number;
|
||||||
Valid: boolean;
|
Valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreditTimestamps {
|
interface CreditTimestamps {
|
||||||
Credits: { Start: number; End: number; Valid: boolean };
|
Introduction: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
|
Credits: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICKS_PER_SECOND = 10_000_000;
|
const TICKS_PER_SECOND = 10000000;
|
||||||
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
|
||||||
|
|
||||||
const emptyBuckets = (): SegmentBuckets => ({
|
|
||||||
introSegments: [],
|
|
||||||
creditSegments: [],
|
|
||||||
recapSegments: [],
|
|
||||||
commercialSegments: [],
|
|
||||||
previewSegments: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useSegments = (
|
export const useSegments = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
@@ -42,6 +48,7 @@ export const useSegments = (
|
|||||||
downloadedFiles: DownloadedItem[] | undefined,
|
downloadedFiles: DownloadedItem[] | undefined,
|
||||||
api: Api | null,
|
api: Api | null,
|
||||||
) => {
|
) => {
|
||||||
|
// Memoize the lookup so the array is only traversed when dependencies change
|
||||||
const downloadedItem = React.useMemo(
|
const downloadedItem = React.useMemo(
|
||||||
() => downloadedFiles?.find((d) => d.item.Id === itemId),
|
() => downloadedFiles?.find((d) => d.item.Id === itemId),
|
||||||
[downloadedFiles, itemId],
|
[downloadedFiles, itemId],
|
||||||
@@ -58,110 +65,141 @@ export const useSegments = (
|
|||||||
}
|
}
|
||||||
return fetchAndParseSegments(itemId, api);
|
return fetchAndParseSegments(itemId, api);
|
||||||
},
|
},
|
||||||
enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
|
enabled: isOffline ? !!downloadedItem : !!api,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
|
export const getSegmentsForItem = (
|
||||||
introSegments: item.introSegments || [],
|
item: DownloadedItem,
|
||||||
creditSegments: item.creditSegments || [],
|
): {
|
||||||
recapSegments: item.recapSegments || [],
|
introSegments: MediaTimeSegment[];
|
||||||
commercialSegments: item.commercialSegments || [],
|
creditSegments: MediaTimeSegment[];
|
||||||
previewSegments: item.previewSegments || [],
|
} => {
|
||||||
});
|
return {
|
||||||
|
introSegments: item.introSegments || [],
|
||||||
|
creditSegments: item.creditSegments || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
|
/**
|
||||||
|
* Converts Jellyfin ticks to seconds
|
||||||
|
*/
|
||||||
|
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches segments using the new Jellyfin 10.11+ MediaSegments API
|
||||||
|
*/
|
||||||
const fetchMediaSegments = async (
|
const fetchMediaSegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets | null> => {
|
): Promise<{
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
} | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await getMediaSegmentsApi(api).getItemSegments({
|
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||||
itemId,
|
`${api.basePath}/MediaSegments/${itemId}`,
|
||||||
includeSegmentTypes: [
|
{
|
||||||
MediaSegmentType.Intro,
|
headers: getAuthHeaders(api),
|
||||||
MediaSegmentType.Outro,
|
params: {
|
||||||
MediaSegmentType.Recap,
|
includeSegmentTypes: ["Intro", "Outro"],
|
||||||
MediaSegmentType.Commercial,
|
},
|
||||||
MediaSegmentType.Preview,
|
},
|
||||||
],
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const buckets = emptyBuckets();
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
for (const segment of response.data.Items ?? []) {
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
if (segment.StartTicks == null || segment.EndTicks == null) continue;
|
|
||||||
|
response.data.Items.forEach((segment) => {
|
||||||
const timeSegment: MediaTimeSegment = {
|
const timeSegment: MediaTimeSegment = {
|
||||||
startTime: ticksToSeconds(segment.StartTicks),
|
startTime: ticksToSeconds(segment.StartTicks),
|
||||||
endTime: ticksToSeconds(segment.EndTicks),
|
endTime: ticksToSeconds(segment.EndTicks),
|
||||||
text: segment.Type ?? "",
|
text: segment.Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (segment.Type) {
|
switch (segment.Type) {
|
||||||
case MediaSegmentType.Intro:
|
case "Intro":
|
||||||
buckets.introSegments.push(timeSegment);
|
introSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case MediaSegmentType.Outro:
|
case "Outro":
|
||||||
buckets.creditSegments.push(timeSegment);
|
creditSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case MediaSegmentType.Recap:
|
// Optionally handle other types like Recap, Commercial, Preview
|
||||||
buckets.recapSegments.push(timeSegment);
|
default:
|
||||||
break;
|
|
||||||
case MediaSegmentType.Commercial:
|
|
||||||
buckets.commercialSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case MediaSegmentType.Preview:
|
|
||||||
buckets.previewSegments.push(timeSegment);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
return buckets;
|
return { introSegments, creditSegments };
|
||||||
} catch {
|
} catch (_error) {
|
||||||
|
// Return null to indicate we should try legacy endpoints
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
|
/**
|
||||||
|
* Fetches segments using legacy pre-10.11 endpoints
|
||||||
|
*/
|
||||||
const fetchLegacySegments = async (
|
const fetchLegacySegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets> => {
|
): Promise<{
|
||||||
const buckets = emptyBuckets();
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
}> => {
|
||||||
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
|
|
||||||
const [introRes, creditRes] = await Promise.allSettled([
|
try {
|
||||||
api.axiosInstance.get<IntroTimestamps>(
|
const [introRes, creditRes] = await Promise.allSettled([
|
||||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
api.axiosInstance.get<IntroTimestamps>(
|
||||||
{ headers: getAuthHeaders(api) },
|
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||||
),
|
{ headers: getAuthHeaders(api) },
|
||||||
api.axiosInstance.get<CreditTimestamps>(
|
),
|
||||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
api.axiosInstance.get<CreditTimestamps>(
|
||||||
{ headers: getAuthHeaders(api) },
|
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||||
),
|
{ headers: getAuthHeaders(api) },
|
||||||
]);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||||
buckets.introSegments.push({
|
introSegments.push({
|
||||||
startTime: introRes.value.data.IntroStart,
|
startTime: introRes.value.data.IntroStart,
|
||||||
endTime: introRes.value.data.IntroEnd,
|
endTime: introRes.value.data.IntroEnd,
|
||||||
text: "Intro",
|
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 (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
|
return { introSegments, creditSegments };
|
||||||
buckets.creditSegments.push({
|
|
||||||
startTime: creditRes.value.data.Credits.Start,
|
|
||||||
endTime: creditRes.value.data.Credits.End,
|
|
||||||
text: "Outro",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return buckets;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAndParseSegments = async (
|
export const fetchAndParseSegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets> => {
|
): Promise<{
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
}> => {
|
||||||
|
// Try new API first (Jellyfin 10.11+)
|
||||||
const newSegments = await fetchMediaSegments(itemId, api);
|
const newSegments = await fetchMediaSegments(itemId, api);
|
||||||
return newSegments ?? fetchLegacySegments(itemId, api);
|
if (newSegments) {
|
||||||
|
return newSegments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy endpoints
|
||||||
|
return fetchLegacySegments(itemId, api);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user