feat(player): add skip intro/credits support for tvOS

This commit is contained in:
Fredrik Burmester
2026-01-30 18:52:22 +01:00
parent 28e3060ace
commit af2cac0e86
7 changed files with 325 additions and 38 deletions

View File

@@ -1198,6 +1198,7 @@ export default function page() {
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
downloadedFiles={downloadedFiles}
/>
) : (
<Controls

View File

@@ -17,7 +17,7 @@ import React, {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, ScrollView, View } from "react-native";
import { Alert, Dimensions, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
@@ -54,7 +54,7 @@ import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { runtimeTicksToMinutes } from "@/utils/time";
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -156,21 +156,59 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
defaultMediaSource,
]);
const navigateToPlayer = useCallback(
(playbackPosition: string) => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition,
offline: isOffline ? "true" : "false",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
},
[item, selectedOptions, isOffline, router],
);
const handlePlay = () => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const hasPlaybackProgress =
(item.UserData?.PlaybackPositionTicks ?? 0) > 0;
router.push(`/player/direct-player?${queryParams.toString()}`);
if (hasPlaybackProgress) {
Alert.alert(
t("item_card.resume_playback"),
t("item_card.resume_playback_description"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("item_card.play_from_start"),
onPress: () => navigateToPlayer("0"),
},
{
text: t("item_card.continue_from", {
time: formatDuration(item.UserData?.PlaybackPositionTicks),
}),
onPress: () =>
navigateToPlayer(
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
),
isPreferred: true,
},
],
);
} else {
navigateToPlayer("0");
}
};
// TV Option Modal hook for quality, audio, media source selectors

View File

@@ -0,0 +1,139 @@
import { Ionicons } from "@expo/vector-icons";
import { type FC, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Animated as RNAnimated,
StyleSheet,
View,
} from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVSkipSegmentCardProps {
show: boolean;
onPress: () => void;
type: "intro" | "credits";
/** Whether this card should capture focus when visible */
hasFocus?: boolean;
/** Whether controls are visible - affects card position */
controlsVisible?: boolean;
}
// 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,
hasFocus = false,
controlsVisible = false,
}) => {
const { t } = useTranslation();
const pressableRef = useRef<View>(null);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.1,
duration: 120,
});
// Programmatically request focus when card appears with hasFocus=true
useEffect(() => {
if (!show || !hasFocus || !pressableRef.current) return;
const timer = setTimeout(() => {
// Use setNativeProps to trigger focus update on tvOS
(pressableRef.current as any)?.setNativeProps?.({
hasTVPreferredFocus: true,
});
}, 50);
return () => clearTimeout(timer);
}, [show, hasFocus]);
// 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 =
type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
if (!show) return null;
return (
<Animated.View
style={[styles.container, containerAnimatedStyle]}
pointerEvents='box-none'
>
<Pressable
ref={pressableRef}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasFocus}
>
<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={20} color='#fff' />
<Text style={styles.label}>{labelText}</Text>
</RNAnimated.View>
</Pressable>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
right: 80,
zIndex: 100,
},
button: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 12,
borderWidth: 2,
gap: 8,
},
label: {
fontSize: 20,
color: "#fff",
fontWeight: "600",
},
});

View File

@@ -53,6 +53,8 @@ export type { TVSeriesNavigationProps } from "./TVSeriesNavigation";
export { TVSeriesNavigation } from "./TVSeriesNavigation";
export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard";
export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
export type { TVSkipSegmentCardProps } from "./TVSkipSegmentCard";
export { TVSkipSegmentCard } from "./TVSkipSegmentCard";
export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard";
export { TVSubtitleResultCard } from "./TVSubtitleResultCard";
export type { TVTabButtonProps } from "./TVTabButton";

View File

@@ -29,16 +29,24 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
import {
TVControlButton,
TVNextEpisodeCountdown,
TVSkipSegmentCard,
} from "@/components/tv";
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import type { TechnicalInfo } from "@/modules/mpv-player";
import type { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -83,6 +91,7 @@ interface Props {
getTechnicalInfo?: () => Promise<TechnicalInfo>;
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
transcodeReasons?: string[];
downloadedFiles?: DownloadedItem[];
}
const TV_SEEKBAR_HEIGHT = 14;
@@ -206,6 +215,7 @@ export const Controls: FC<Props> = ({
getTechnicalInfo,
playMethod,
transcodeReasons,
downloadedFiles,
}) => {
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
@@ -391,6 +401,31 @@ export const Controls: FC<Props> = ({
seek,
});
// Skip intro/credits hooks
// Note: hooks expect seek callback that takes ms, and seek prop already expects ms
const offline = useOfflineMode();
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!,
currentTime,
seek,
_play,
offline,
api,
downloadedFiles,
);
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
useCreditSkipper(
item.Id!,
currentTime,
seek,
_play,
offline,
api,
downloadedFiles,
max.value,
);
// Countdown logic - needs to be early so toggleControls can reference it
const isCountdownActive = useMemo(() => {
if (!nextItem) return false;
@@ -398,6 +433,13 @@ export const Controls: FC<Props> = ({
return remainingTime > 0 && remainingTime <= 10000;
}, [nextItem, item, remainingTime]);
// Whether any skip card is visible - used to prevent focus conflicts
const isSkipCardVisible =
(showSkipButton && !isCountdownActive) ||
(showSkipCreditButton &&
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive);
// Brief delay to ignore focus events when countdown first appears
const countdownJustActivatedRef = useRef(false);
@@ -413,6 +455,41 @@ export const Controls: FC<Props> = ({
return () => clearTimeout(timeout);
}, [isCountdownActive]);
// Brief delay to ignore focus events when skip card first appears
const skipCardJustActivatedRef = useRef(false);
useEffect(() => {
if (!isSkipCardVisible) {
skipCardJustActivatedRef.current = false;
return;
}
skipCardJustActivatedRef.current = true;
const timeout = setTimeout(() => {
skipCardJustActivatedRef.current = false;
}, 200);
return () => clearTimeout(timeout);
}, [isSkipCardVisible]);
// Brief delay to ignore focus events after pressing skip button
const skipJustPressedRef = useRef(false);
// Wrapper to prevent focus events after skip actions
const handleSkipWithDelay = useCallback((skipFn: () => void) => {
skipJustPressedRef.current = true;
skipFn();
setTimeout(() => {
skipJustPressedRef.current = false;
}, 500);
}, []);
const handleSkipIntro = useCallback(() => {
handleSkipWithDelay(skipIntro);
}, [handleSkipWithDelay, skipIntro]);
const handleSkipCredit = useCallback(() => {
handleSkipWithDelay(skipCredit);
}, [handleSkipWithDelay, skipCredit]);
// Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels)
const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel";
@@ -430,8 +507,12 @@ export const Controls: FC<Props> = ({
};
const toggleControls = useCallback(() => {
// Skip if countdown just became active (ignore initial focus event)
if (countdownJustActivatedRef.current) return;
// Skip if countdown or skip card just became active (ignore initial focus event)
const shouldIgnore =
countdownJustActivatedRef.current ||
skipCardJustActivatedRef.current ||
skipJustPressedRef.current;
if (shouldIgnore) return;
setShowControls(!showControls);
}, [showControls, setShowControls]);
@@ -459,10 +540,6 @@ export const Controls: FC<Props> = ({
setSeekBubbleTime({ hours, minutes, seconds });
}, []);
const handleBack = useCallback(() => {
// No longer needed since modals are screen-based
}, []);
// Show minimal seek bar (only progress bar, no buttons)
const showMinimalSeek = useCallback(() => {
setShowMinimalSeekBar(true);
@@ -499,16 +576,6 @@ export const Controls: FC<Props> = ({
}, 2500);
}, []);
// Reset minimal seek bar timeout (call on each seek action)
const _resetMinimalSeekTimeout = useCallback(() => {
if (minimalSeekBarTimeoutRef.current) {
clearTimeout(minimalSeekBarTimeoutRef.current);
}
minimalSeekBarTimeoutRef.current = setTimeout(() => {
setShowMinimalSeekBar(false);
}, 2500);
}, []);
const handleOpenAudioSheet = useCallback(() => {
setLastOpenedModal("audio");
showOptions({
@@ -875,8 +942,12 @@ export const Controls: FC<Props> = ({
// Callback for up/down D-pad - show controls with play button focused
const handleVerticalDpad = useCallback(() => {
// Skip if countdown just became active (ignore initial focus event)
if (countdownJustActivatedRef.current) return;
// Skip if countdown or skip card just became active (ignore initial focus event)
const shouldIgnore =
countdownJustActivatedRef.current ||
skipCardJustActivatedRef.current ||
skipJustPressedRef.current;
if (shouldIgnore) return;
setFocusPlayButton(true);
setShowControls(true);
}, [setShowControls]);
@@ -885,7 +956,6 @@ export const Controls: FC<Props> = ({
showControls,
toggleControls,
togglePlay,
onBack: handleBack,
isProgressBarFocused,
onSeekLeft: handleProgressSeekLeft,
onSeekRight: handleProgressSeekRight,
@@ -1008,6 +1078,33 @@ export const Controls: FC<Props> = ({
/>
)}
{/* Skip intro card */}
<TVSkipSegmentCard
show={showSkipButton && !isCountdownActive}
onPress={handleSkipIntro}
type='intro'
hasFocus={showSkipButton && !isCountdownActive}
controlsVisible={showControls}
/>
{/* Skip credits card - show when there's content after credits, OR no next episode */}
<TVSkipSegmentCard
show={
showSkipCreditButton &&
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive
}
onPress={handleSkipCredit}
type='credits'
hasFocus={
showSkipCreditButton &&
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive &&
!showSkipButton
}
controlsVisible={showControls}
/>
{nextItem && (
<TVNextEpisodeCountdown
nextItem={nextItem}
@@ -1280,6 +1377,7 @@ export const Controls: FC<Props> = ({
refSetter={setProgressBarRef}
hasTVPreferredFocus={
!isCountdownActive &&
!isSkipCardVisible &&
lastOpenedModal === null &&
!focusPlayButton
}

View File

@@ -101,9 +101,7 @@ export function useRemoteControl({
// Handle play/pause button press on TV remote
if (evt.eventType === "playPause") {
if (togglePlay) {
togglePlay();
}
togglePlay?.();
onInteraction?.();
return;
}
@@ -134,6 +132,11 @@ export function useRemoteControl({
// Handle D-pad when controls are hidden
if (!showControls) {
// Ignore select/enter events - let the native Pressable handle them
// This prevents controls from showing when pressing buttons like skip intro
if (evt.eventType === "select" || evt.eventType === "enter") {
return;
}
// Minimal seek mode for left/right
if (evt.eventType === "left" && onMinimalSeekLeft) {
onMinimalSeekLeft();

View File

@@ -671,7 +671,9 @@
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings"
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits"
},
"item_card": {
"next_up": "Next Up",
@@ -722,7 +724,11 @@
"download_button": "Download"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched"
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Next",