feat(tv): add focus management to next episode countdown card

This commit is contained in:
Fredrik Burmester
2026-01-29 18:22:28 +01:00
parent 53902aebab
commit 3827350ffd
2 changed files with 141 additions and 55 deletions

View File

@@ -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<TVNextEpisodeCountdownProps> = ({
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<TVNextEpisodeCountdownProps> = ({
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<TVNextEpisodeCountdownProps> = ({
if (!show) return null;
return (
<View style={styles.container} pointerEvents='none'>
<BlurView intensity={80} tint='dark' style={styles.blur}>
<View style={styles.innerContainer}>
{imageUrl && (
<Image
source={{ uri: imageUrl }}
style={styles.thumbnail}
resizeMode='cover'
/>
)}
<Animated.View
style={[styles.container, containerAnimatedStyle]}
pointerEvents='box-none'
>
<Pressable
onPress={onPlayNext}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasFocus}
focusable={true}
>
<RNAnimated.View style={[animatedStyle, focused && styles.focusedCard]}>
<BlurView intensity={80} tint='dark' style={styles.blur}>
<View style={styles.innerContainer}>
{imageUrl && (
<Image
source={{ uri: imageUrl }}
style={styles.thumbnail}
resizeMode='cover'
/>
)}
<View style={styles.content}>
<Text style={styles.label}>{t("player.next_episode")}</Text>
<View style={styles.content}>
<Text style={styles.label}>{t("player.next_episode")}</Text>
<Text style={styles.seriesName} numberOfLines={1}>
{nextItem.SeriesName}
</Text>
<Text style={styles.seriesName} numberOfLines={1}>
{nextItem.SeriesName}
</Text>
<Text style={styles.episodeInfo} numberOfLines={1}>
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
{nextItem.Name}
</Text>
<Text style={styles.episodeInfo} numberOfLines={1}>
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
{nextItem.Name}
</Text>
<View style={styles.progressContainer}>
<Animated.View style={[styles.progressBar, progressStyle]} />
<View style={styles.progressContainer}>
<Animated.View style={[styles.progressBar, progressStyle]} />
</View>
</View>
</View>
</View>
</View>
</BlurView>
</View>
</BlurView>
</RNAnimated.View>
</Pressable>
</Animated.View>
);
};
@@ -112,10 +183,15 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
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",

View File

@@ -376,13 +376,26 @@ export const Controls: FC<Props> = ({
});
// 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<Props> = ({
};
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<Props> = ({
}, []);
// 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<Props> = ({
<TVNextEpisodeCountdown
nextItem={nextItem}
api={api}
show={shouldShowCountdown}
show={isCountdownActive}
isPlaying={isPlaying}
onFinish={handleAutoPlayFinish}
onPlayNext={handleNextItemButton}
@@ -1117,13 +1132,12 @@ export const Controls: FC<Props> = ({
<TVControlButton
icon='play-skip-back'
onPress={handlePreviousItem}
disabled={isCountdownActive || !previousItem}
disabled={!previousItem}
size={28}
/>
<TVControlButton
icon={isPlaying ? "pause" : "play"}
onPress={handlePlayPauseButton}
disabled={isCountdownActive}
size={36}
refSetter={setPlayButtonRef}
hasTVPreferredFocus={
@@ -1135,7 +1149,7 @@ export const Controls: FC<Props> = ({
<TVControlButton
icon='play-skip-forward'
onPress={handleNextItemButton}
disabled={isCountdownActive || !nextItem}
disabled={!nextItem}
size={28}
/>
@@ -1145,7 +1159,6 @@ export const Controls: FC<Props> = ({
<TVControlButton
icon='volume-high'
onPress={handleOpenAudioSheet}
disabled={isCountdownActive}
hasTVPreferredFocus={
!isCountdownActive && lastOpenedModal === "audio"
}
@@ -1156,7 +1169,6 @@ export const Controls: FC<Props> = ({
<TVControlButton
icon='text'
onPress={handleOpenSubtitleSheet}
disabled={isCountdownActive}
hasTVPreferredFocus={
!isCountdownActive && lastOpenedModal === "subtitle"
}
@@ -1167,7 +1179,6 @@ export const Controls: FC<Props> = ({
<TVControlButton
icon='code-slash'
onPress={handleToggleTechnicalInfo}
disabled={isCountdownActive}
hasTVPreferredFocus={
!isCountdownActive && lastOpenedModal === "techInfo"
}
@@ -1213,7 +1224,6 @@ export const Controls: FC<Props> = ({
onFocus={() => setIsProgressBarFocused(true)}
onBlur={() => setIsProgressBarFocused(false)}
refSetter={setProgressBarRef}
disabled={isCountdownActive}
hasTVPreferredFocus={
!isCountdownActive &&
lastOpenedModal === null &&