mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 01:28:27 +01:00
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.
228 lines
6.9 KiB
TypeScript
228 lines
6.9 KiB
TypeScript
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
import type { FC } from "react";
|
|
import { View } from "react-native";
|
|
import { Slider } from "react-native-awesome-slider";
|
|
import { type SharedValue } from "react-native-reanimated";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Text } from "@/components/common/Text";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
|
import SkipButton from "./SkipButton";
|
|
import { TimeDisplay } from "./TimeDisplay";
|
|
import { TrickplayBubble } from "./TrickplayBubble";
|
|
|
|
interface BottomControlsProps {
|
|
item: BaseItemDto;
|
|
showControls: boolean;
|
|
isSliding: boolean;
|
|
showRemoteBubble: boolean;
|
|
currentTime: number;
|
|
remainingTime: number;
|
|
showSkipSegmentButton: boolean;
|
|
skipSegmentButtonText: string;
|
|
showSkipOutroButton: boolean;
|
|
skipOutroButtonText: string;
|
|
hasContentAfterCredits: boolean;
|
|
onSkipSegment: () => void;
|
|
onSkipOutro: () => void;
|
|
nextItem?: BaseItemDto | null;
|
|
handleNextEpisodeAutoPlay: () => void;
|
|
handleNextEpisodeManual: () => void;
|
|
handleControlsInteraction: () => void;
|
|
|
|
// Slider props
|
|
min: SharedValue<number>;
|
|
max: SharedValue<number>;
|
|
effectiveProgress: SharedValue<number>;
|
|
cacheProgress: SharedValue<number>;
|
|
handleSliderStart: () => void;
|
|
handleSliderComplete: (value: number) => void;
|
|
handleSliderChange: (value: number) => void;
|
|
handleTouchStart: () => void;
|
|
handleTouchEnd: () => void;
|
|
|
|
// Trickplay props
|
|
trickPlayUrl: {
|
|
x: number;
|
|
y: number;
|
|
url: string;
|
|
} | null;
|
|
trickplayInfo: {
|
|
aspectRatio?: number;
|
|
data: {
|
|
TileWidth?: number;
|
|
TileHeight?: number;
|
|
};
|
|
} | null;
|
|
time: {
|
|
hours: number;
|
|
minutes: number;
|
|
seconds: number;
|
|
};
|
|
}
|
|
|
|
export const BottomControls: FC<BottomControlsProps> = ({
|
|
item,
|
|
showControls,
|
|
isSliding,
|
|
showRemoteBubble,
|
|
currentTime,
|
|
remainingTime,
|
|
showSkipSegmentButton,
|
|
skipSegmentButtonText,
|
|
showSkipOutroButton,
|
|
skipOutroButtonText,
|
|
hasContentAfterCredits,
|
|
onSkipSegment,
|
|
onSkipOutro,
|
|
nextItem,
|
|
handleNextEpisodeAutoPlay,
|
|
handleNextEpisodeManual,
|
|
handleControlsInteraction,
|
|
min,
|
|
max,
|
|
effectiveProgress,
|
|
cacheProgress,
|
|
handleSliderStart,
|
|
handleSliderComplete,
|
|
handleSliderChange,
|
|
handleTouchStart,
|
|
handleTouchEnd,
|
|
trickPlayUrl,
|
|
trickplayInfo,
|
|
time,
|
|
}) => {
|
|
const { settings } = useSettings();
|
|
const insets = useSafeAreaInsets();
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
{
|
|
position: "absolute",
|
|
right:
|
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
|
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
|
bottom:
|
|
(settings?.safeAreaInControlsEnabled ?? true)
|
|
? Math.max(insets.bottom - 17, 0)
|
|
: 0,
|
|
},
|
|
]}
|
|
className={"flex flex-col px-2"}
|
|
onTouchStart={handleControlsInteraction}
|
|
>
|
|
<View
|
|
className='shrink flex flex-col justify-center h-full'
|
|
style={{
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<View
|
|
className='flex flex-col items-start shrink'
|
|
pointerEvents={showControls ? "box-none" : "none"}
|
|
>
|
|
{item?.Type === "Episode" && (
|
|
<Text className='opacity-50'>
|
|
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
|
|
</Text>
|
|
)}
|
|
<Text className='font-bold text-xl'>{item?.Name}</Text>
|
|
{item?.Type === "Movie" && (
|
|
<Text className='text-xs opacity-50'>{item?.ProductionYear}</Text>
|
|
)}
|
|
{item?.Type === "Audio" && (
|
|
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
|
)}
|
|
</View>
|
|
<View className='flex flex-row space-x-2 shrink-0'>
|
|
<SkipButton
|
|
showButton={showSkipSegmentButton}
|
|
onPress={onSkipSegment}
|
|
buttonText={skipSegmentButtonText}
|
|
/>
|
|
{/* Outro button defers to "Next Episode" when credits run to the
|
|
video end and a next episode exists. */}
|
|
<SkipButton
|
|
showButton={
|
|
showSkipOutroButton && (hasContentAfterCredits || !nextItem)
|
|
}
|
|
onPress={onSkipOutro}
|
|
buttonText={skipOutroButtonText}
|
|
/>
|
|
{settings.autoPlayNextEpisode !== false &&
|
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
|
settings.autoPlayEpisodeCount <
|
|
settings.maxAutoPlayEpisodeCount.value) && (
|
|
<NextEpisodeCountDownButton
|
|
show={
|
|
!nextItem
|
|
? false
|
|
: // Show during credits if no content after, OR near end of video
|
|
(showSkipOutroButton && !hasContentAfterCredits) ||
|
|
remainingTime < 10000
|
|
}
|
|
onFinish={handleNextEpisodeAutoPlay}
|
|
onPress={handleNextEpisodeManual}
|
|
/>
|
|
)}
|
|
</View>
|
|
</View>
|
|
<View
|
|
className={"flex flex-col-reverse rounded-lg items-center my-2"}
|
|
pointerEvents={showControls ? "box-none" : "none"}
|
|
>
|
|
<View className={"flex flex-col w-full shrink"}>
|
|
<View
|
|
style={{
|
|
height: 10,
|
|
justifyContent: "center",
|
|
alignItems: "stretch",
|
|
}}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
<Slider
|
|
theme={{
|
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
|
minimumTrackTintColor: "#fff",
|
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
|
bubbleBackgroundColor: "#fff",
|
|
bubbleTextColor: "#666",
|
|
heartbeatColor: "#999",
|
|
}}
|
|
renderThumb={() => null}
|
|
cache={cacheProgress}
|
|
onSlidingStart={handleSliderStart}
|
|
onSlidingComplete={handleSliderComplete}
|
|
onValueChange={handleSliderChange}
|
|
containerStyle={{
|
|
borderRadius: 100,
|
|
}}
|
|
renderBubble={() =>
|
|
(isSliding || showRemoteBubble) && (
|
|
<TrickplayBubble
|
|
trickPlayUrl={trickPlayUrl}
|
|
trickplayInfo={trickplayInfo}
|
|
time={time}
|
|
/>
|
|
)
|
|
}
|
|
sliderHeight={10}
|
|
thumbWidth={0}
|
|
progress={effectiveProgress}
|
|
minimumValue={min}
|
|
maximumValue={max}
|
|
/>
|
|
</View>
|
|
<TimeDisplay
|
|
currentTime={currentTime}
|
|
remainingTime={remainingTime}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|