Files
streamyfin/components/tv/TVSkipSegmentCard.tsx
Gauvain cec2c4a712 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.
2026-06-15 01:33:42 +02:00

161 lines
4.1 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Animated as RNAnimated,
StyleSheet,
TVFocusGuideView,
type View,
} from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
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<TVSkipSegmentType, string> = {
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: TVSkipSegmentType;
/** Whether controls are visible - affects card position */
controlsVisible?: boolean;
/** Callback ref setter for focus guide destination pattern */
refSetter?: (ref: View | null) => void;
/** Whether this component should receive initial focus */
hasTVPreferredFocus?: boolean;
/** Destination used when moving down from this card */
playButtonRef?: View | null;
}
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
const BOTTOM_WITH_CONTROLS = 300;
const BOTTOM_WITHOUT_CONTROLS = 120;
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
show,
onPress,
type,
controlsVisible = false,
refSetter,
hasTVPreferredFocus = true,
playButtonRef: downDestination,
}) => {
const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.1,
duration: 120,
});
// Animated position based on controls visibility
const bottomPosition = useSharedValue(
controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS,
);
useEffect(() => {
const target = controlsVisible
? BOTTOM_WITH_CONTROLS
: BOTTOM_WITHOUT_CONTROLS;
bottomPosition.value = withTiming(target, {
duration: 300,
easing: Easing.out(Easing.quad),
});
}, [controlsVisible, bottomPosition]);
const containerAnimatedStyle = useAnimatedStyle(() => ({
bottom: bottomPosition.value,
}));
const labelText = t(SEGMENT_LABEL_KEY[type]);
if (!show) return null;
return (
<Animated.View
style={[styles.container, containerAnimatedStyle]}
pointerEvents='box-none'
>
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
style={[
styles.button,
animatedStyle,
{
backgroundColor: focused
? "rgba(255,255,255,0.3)"
: "rgba(255,255,255,0.1)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.2)",
},
]}
>
<Ionicons name='play-forward' size={scaleSize(20)} color='#fff' />
<Text style={styles.label}>{labelText}</Text>
</RNAnimated.View>
</Pressable>
{downDestination && (
<TVFocusGuideView
destinations={[downDestination]}
style={styles.returnFocusGuide}
/>
)}
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
right: scaleSize(80),
zIndex: 100,
},
button: {
flexDirection: "row",
alignItems: "center",
paddingVertical: scaleSize(10),
paddingHorizontal: scaleSize(18),
borderRadius: scaleSize(12),
borderWidth: scaleSize(2),
gap: scaleSize(8),
},
label: {
fontSize: scaleSize(20),
color: "#fff",
fontWeight: "600",
},
returnFocusGuide: {
height: 1,
width: "100%",
},
});