From af2cac0e86df1938fa9a6a63a60758733a2663b8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 18:52:22 +0100 Subject: [PATCH] feat(player): add skip intro/credits support for tvOS --- app/(auth)/player/direct-player.tsx | 1 + components/ItemContent.tv.tsx | 64 ++++++-- components/tv/TVSkipSegmentCard.tsx | 139 ++++++++++++++++++ components/tv/index.ts | 2 + .../video-player/controls/Controls.tv.tsx | 138 ++++++++++++++--- .../controls/hooks/useRemoteControl.ts | 9 +- translations/en.json | 10 +- 7 files changed, 325 insertions(+), 38 deletions(-) create mode 100644 components/tv/TVSkipSegmentCard.tsx diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 00f3e74f..5725c56c 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -1198,6 +1198,7 @@ export default function page() { getTechnicalInfo={getTechnicalInfo} playMethod={playMethod} transcodeReasons={transcodeReasons} + downloadedFiles={downloadedFiles} /> ) : ( = 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 diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx new file mode 100644 index 00000000..f735e7d5 --- /dev/null +++ b/components/tv/TVSkipSegmentCard.tsx @@ -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 = ({ + show, + onPress, + type, + hasFocus = false, + controlsVisible = false, +}) => { + const { t } = useTranslation(); + const pressableRef = useRef(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 ( + + + + + {labelText} + + + + ); +}; + +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", + }, +}); diff --git a/components/tv/index.ts b/components/tv/index.ts index 527ef22e..76a6e60e 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -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"; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 69b91790..81ab1542 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -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; playMethod?: "DirectPlay" | "DirectStream" | "Transcode"; transcodeReasons?: string[]; + downloadedFiles?: DownloadedItem[]; } const TV_SEEKBAR_HEIGHT = 14; @@ -206,6 +215,7 @@ export const Controls: FC = ({ getTechnicalInfo, playMethod, transcodeReasons, + downloadedFiles, }) => { const typography = useScaledTVTypography(); const insets = useSafeAreaInsets(); @@ -391,6 +401,31 @@ export const Controls: FC = ({ 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 = ({ 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 = ({ 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 = ({ }; 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 = ({ 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 = ({ }, 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 = ({ // 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 = ({ showControls, toggleControls, togglePlay, - onBack: handleBack, isProgressBarFocused, onSeekLeft: handleProgressSeekLeft, onSeekRight: handleProgressSeekRight, @@ -1008,6 +1078,33 @@ export const Controls: FC = ({ /> )} + {/* Skip intro card */} + + + {/* Skip credits card - show when there's content after credits, OR no next episode */} + + {nextItem && ( = ({ refSetter={setProgressBarRef} hasTVPreferredFocus={ !isCountdownActive && + !isSkipCardVisible && lastOpenedModal === null && !focusPlayButton } diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index c813d141..f30fda23 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -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(); diff --git a/translations/en.json b/translations/en.json index 18a6fbbd..78edbee0 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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",