From 3827350ffd98adca0c9b3b52c267c00c3f10f68a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 18:22:28 +0100 Subject: [PATCH] feat(tv): add focus management to next episode countdown card --- components/tv/TVNextEpisodeCountdown.tsx | 160 +++++++++++++----- .../video-player/controls/Controls.tv.tsx | 36 ++-- 2 files changed, 141 insertions(+), 55 deletions(-) diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index ef1ea6cc..6462b659 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -3,7 +3,13 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BlurView } from "expo-blur"; import { type FC, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Image, StyleSheet, View } from "react-native"; +import { + Image, + Pressable, + Animated as RNAnimated, + StyleSheet, + View, +} from "react-native"; import Animated, { cancelAnimation, Easing, @@ -15,6 +21,7 @@ import Animated, { import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVNextEpisodeCountdownProps { nextItem: BaseItemDto; @@ -22,19 +29,37 @@ export interface TVNextEpisodeCountdownProps { show: boolean; isPlaying: boolean; onFinish: () => void; + /** Called when user presses the card to skip to next episode */ + onPlayNext?: () => void; + /** Whether this card should capture focus when visible */ + hasFocus?: boolean; + /** Whether controls are visible - affects card position */ + controlsVisible?: boolean; } +// Position constants +const BOTTOM_WITH_CONTROLS = 300; +const BOTTOM_WITHOUT_CONTROLS = 120; + export const TVNextEpisodeCountdown: FC = ({ nextItem, api, show, isPlaying, onFinish, + onPlayNext, + hasFocus = false, + controlsVisible = false, }) => { const typography = useScaledTVTypography(); const { t } = useTranslation(); const progress = useSharedValue(0); const onFinishRef = useRef(onFinish); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); onFinishRef.current = onFinish; @@ -45,25 +70,58 @@ export const TVNextEpisodeCountdown: FC = ({ quality: 80, }); + // Animated position based on controls visibility + const bottomPosition = useSharedValue( + controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS, + ); + useEffect(() => { - if (show && isPlaying) { - progress.value = 0; - progress.value = withTiming( - 1, - { - duration: 8000, - easing: Easing.linear, - }, - (finished) => { - if (finished && onFinishRef.current) { - runOnJS(onFinishRef.current)(); - } - }, - ); - } else { + 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, + })); + + // Progress animation - pause/resume without resetting + const prevShowRef = useRef(false); + + useEffect(() => { + const justStartedShowing = show && !prevShowRef.current; + prevShowRef.current = show; + + if (!show) { cancelAnimation(progress); progress.value = 0; + return; } + + if (justStartedShowing) { + progress.value = 0; + } + + if (!isPlaying) { + cancelAnimation(progress); + return; + } + + // Resume from current position + const remainingDuration = (1 - progress.value) * 8000; + progress.value = withTiming( + 1, + { duration: remainingDuration, easing: Easing.linear }, + (finished) => { + if (finished) { + runOnJS(onFinishRef.current)(); + } + }, + ); }, [show, isPlaying, progress]); const progressStyle = useAnimatedStyle(() => ({ @@ -75,36 +133,49 @@ export const TVNextEpisodeCountdown: FC = ({ if (!show) return null; return ( - - - - {imageUrl && ( - - )} + + + + + + {imageUrl && ( + + )} - - {t("player.next_episode")} + + {t("player.next_episode")} - - {nextItem.SeriesName} - + + {nextItem.SeriesName} + - - S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} - {nextItem.Name} - + + S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} + {nextItem.Name} + - - + + + + - - - - + + + + ); }; @@ -112,10 +183,15 @@ const createStyles = (typography: ReturnType) => StyleSheet.create({ container: { position: "absolute", - bottom: 180, right: 80, zIndex: 100, }, + focusedCard: { + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.6, + shadowRadius: 16, + }, blur: { borderRadius: 16, overflow: "hidden", diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index bf4d2eed..42b584a0 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -376,13 +376,26 @@ export const Controls: FC = ({ }); // Countdown logic - needs to be early so toggleControls can reference it - const shouldShowCountdown = useMemo(() => { + const isCountdownActive = useMemo(() => { if (!nextItem) return false; if (item?.Type !== "Episode") return false; return remainingTime > 0 && remainingTime <= 10000; }, [nextItem, item, remainingTime]); - const isCountdownActive = shouldShowCountdown; + // Brief delay to ignore focus events when countdown first appears + const countdownJustActivatedRef = useRef(false); + + useEffect(() => { + if (!isCountdownActive) { + countdownJustActivatedRef.current = false; + return; + } + countdownJustActivatedRef.current = true; + const timeout = setTimeout(() => { + countdownJustActivatedRef.current = false; + }, 200); + return () => clearTimeout(timeout); + }, [isCountdownActive]); // 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"; @@ -401,6 +414,8 @@ export const Controls: FC = ({ }; const toggleControls = useCallback(() => { + // Skip if countdown just became active (ignore initial focus event) + if (countdownJustActivatedRef.current) return; setShowControls(!showControls); }, [showControls, setShowControls]); @@ -843,12 +858,12 @@ export const Controls: FC = ({ }, []); // Callback for up/down D-pad - show controls with play button focused - // Skip if countdown is active (card has focus, don't show controls) const handleVerticalDpad = useCallback(() => { - if (isCountdownActive) return; + // Skip if countdown just became active (ignore initial focus event) + if (countdownJustActivatedRef.current) return; setFocusPlayButton(true); setShowControls(true); - }, [setShowControls, isCountdownActive]); + }, [setShowControls]); const { isSliding: isRemoteSliding } = useRemoteControl({ showControls, @@ -981,7 +996,7 @@ export const Controls: FC = ({ = ({ = ({ @@ -1145,7 +1159,6 @@ export const Controls: FC = ({ = ({ = ({ = ({ onFocus={() => setIsProgressBarFocused(true)} onBlur={() => setIsProgressBarFocused(false)} refSetter={setProgressBarRef} - disabled={isCountdownActive} hasTVPreferredFocus={ !isCountdownActive && lastOpenedModal === null &&