feat: seekbar left/right actions

This commit is contained in:
Fredrik Burmester
2026-01-20 22:15:00 +01:00
parent 11b6f16cd3
commit d8512897ad
4 changed files with 286 additions and 46 deletions

View File

@@ -1,6 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react";
import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native";
import {
Pressable,
Animated as RNAnimated,
StyleSheet,
type View,
} from "react-native";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVControlButtonProps {
@@ -12,6 +17,8 @@ export interface TVControlButtonProps {
hasTVPreferredFocus?: boolean;
size?: number;
delayLongPress?: number;
/** Callback ref setter for focus guide destination pattern */
refSetter?: (ref: View | null) => void;
}
export const TVControlButton: FC<TVControlButtonProps> = ({
@@ -23,12 +30,14 @@ export const TVControlButton: FC<TVControlButtonProps> = ({
hasTVPreferredFocus,
size = 32,
delayLongPress = 300,
refSetter,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 });
return (
<Pressable
ref={refSetter}
onPress={onPress}
onLongPress={onLongPress}
onPressOut={onPressOut}

View File

@@ -0,0 +1,137 @@
import React from "react";
import {
Animated,
Pressable,
StyleSheet,
View,
type ViewStyle,
} from "react-native";
import type { SharedValue } from "react-native-reanimated";
import ReanimatedModule, { useAnimatedStyle } from "react-native-reanimated";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
const ReanimatedView = ReanimatedModule.View;
export interface TVFocusableProgressBarProps {
/** Progress value (SharedValue) in milliseconds */
progress: SharedValue<number>;
/** Maximum value in milliseconds */
max: SharedValue<number>;
/** Cache progress value (SharedValue) in milliseconds */
cacheProgress?: SharedValue<number>;
/** Callback when the progress bar receives focus */
onFocus?: () => void;
/** Callback when the progress bar loses focus */
onBlur?: () => void;
/** Callback ref setter for focus guide destination pattern */
refSetter?: (ref: View | null) => void;
/** Whether this component is disabled */
disabled?: boolean;
/** Whether this component should receive initial focus */
hasTVPreferredFocus?: boolean;
/** Optional style overrides */
style?: ViewStyle;
}
const PROGRESS_BAR_HEIGHT = 16;
export const TVFocusableProgressBar: React.FC<TVFocusableProgressBarProps> =
React.memo(
({
progress,
max,
cacheProgress,
onFocus,
onBlur,
refSetter,
disabled = false,
hasTVPreferredFocus = false,
style,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.02,
duration: 120,
onFocus,
onBlur,
});
const progressFillStyle = useAnimatedStyle(() => ({
width: `${max.value > 0 ? (progress.value / max.value) * 100 : 0}%`,
}));
const cacheProgressStyle = useAnimatedStyle(() => ({
width: `${max.value > 0 && cacheProgress ? (cacheProgress.value / max.value) * 100 : 0}%`,
}));
return (
<Pressable
ref={refSetter}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
style={[styles.pressableContainer, style]}
>
<Animated.View
style={[
styles.animatedContainer,
animatedStyle,
{
borderColor: focused ? "rgba(255,255,255,0.8)" : "transparent",
borderWidth: 2,
},
]}
>
<View style={styles.progressTrack}>
{cacheProgress && (
<ReanimatedView
style={[styles.cacheProgress, cacheProgressStyle]}
/>
)}
<ReanimatedView
style={[styles.progressFill, progressFillStyle]}
/>
</View>
</Animated.View>
</Pressable>
);
},
);
const styles = StyleSheet.create({
pressableContainer: {
// Add padding for focus scale animation to not clip
paddingVertical: 4,
paddingHorizontal: 4,
},
animatedContainer: {
height: PROGRESS_BAR_HEIGHT + 8,
justifyContent: "center",
borderRadius: 12,
paddingHorizontal: 4,
},
progressTrack: {
height: PROGRESS_BAR_HEIGHT,
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 8,
overflow: "hidden",
},
cacheProgress: {
position: "absolute",
top: 0,
left: 0,
height: "100%",
backgroundColor: "rgba(255,255,255,0.3)",
borderRadius: 8,
},
progressFill: {
position: "absolute",
top: 0,
left: 0,
height: "100%",
backgroundColor: "#fff",
borderRadius: 8,
},
});

View File

@@ -13,7 +13,7 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
import { StyleSheet, TVFocusGuideView, View } from "react-native";
import Animated, {
Easing,
type SharedValue,
@@ -25,6 +25,7 @@ import Animated, {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
import useRouter from "@/hooks/useAppRouter";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
@@ -128,6 +129,11 @@ export const Controls: FC<Props> = ({
type LastModalType = "audio" | "subtitle" | null;
const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null);
// State for progress bar focus and focus guide refs
const [isProgressBarFocused, setIsProgressBarFocused] = useState(false);
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [progressBarRef, setProgressBarRef] = useState<View | null>(null);
const audioTracks = useMemo(() => {
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
}, [mediaSource]);
@@ -249,13 +255,6 @@ export const Controls: FC<Props> = ({
// No longer needed since modals are screen-based
}, []);
const { isSliding: isRemoteSliding } = useRemoteControl({
showControls,
toggleControls,
togglePlay,
onBack: handleBack,
});
const handleOpenAudioSheet = useCallback(() => {
setLastOpenedModal("audio");
showOptions({
@@ -319,21 +318,6 @@ export const Controls: FC<Props> = ({
[],
);
const hideControls = useCallback(() => {
setShowControls(false);
}, [setShowControls]);
const { handleControlsInteraction } = useControlsTimeout({
showControls,
isSliding: isRemoteSliding,
episodeView: false,
onHideControls: hideControls,
timeout: TV_AUTO_HIDE_TIMEOUT,
disabled: false,
});
controlsInteractionRef.current = handleControlsInteraction;
const handleSeekForwardButton = useCallback(() => {
const newPosition = Math.min(max.value, progress.value + 30 * 1000);
progress.value = newPosition;
@@ -372,6 +356,76 @@ export const Controls: FC<Props> = ({
controlsInteractionRef.current();
}, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
// Progress bar D-pad seeking (10s increments for finer control)
const handleProgressSeekRight = useCallback(() => {
const newPosition = Math.min(max.value, progress.value + 10 * 1000);
progress.value = newPosition;
seek(newPosition);
calculateTrickplayUrl(msToTicks(newPosition));
updateSeekBubbleTime(newPosition);
setShowSeekBubble(true);
if (seekBubbleTimeoutRef.current) {
clearTimeout(seekBubbleTimeoutRef.current);
}
seekBubbleTimeoutRef.current = setTimeout(() => {
setShowSeekBubble(false);
}, 2000);
controlsInteractionRef.current();
}, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
const handleProgressSeekLeft = useCallback(() => {
const newPosition = Math.max(min.value, progress.value - 10 * 1000);
progress.value = newPosition;
seek(newPosition);
calculateTrickplayUrl(msToTicks(newPosition));
updateSeekBubbleTime(newPosition);
setShowSeekBubble(true);
if (seekBubbleTimeoutRef.current) {
clearTimeout(seekBubbleTimeoutRef.current);
}
seekBubbleTimeoutRef.current = setTimeout(() => {
setShowSeekBubble(false);
}, 2000);
controlsInteractionRef.current();
}, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
// Callback for remote interactions to reset timeout
const handleRemoteInteraction = useCallback(() => {
controlsInteractionRef.current();
}, []);
const { isSliding: isRemoteSliding } = useRemoteControl({
showControls,
toggleControls,
togglePlay,
onBack: handleBack,
isProgressBarFocused,
onSeekLeft: handleProgressSeekLeft,
onSeekRight: handleProgressSeekRight,
onInteraction: handleRemoteInteraction,
});
const hideControls = useCallback(() => {
setShowControls(false);
}, [setShowControls]);
const { handleControlsInteraction } = useControlsTimeout({
showControls,
isSliding: isRemoteSliding,
episodeView: false,
onHideControls: hideControls,
timeout: TV_AUTO_HIDE_TIMEOUT,
disabled: false,
});
controlsInteractionRef.current = handleControlsInteraction;
const stopContinuousSeeking = useCallback(() => {
if (continuousSeekRef.current) {
clearInterval(continuousSeekRef.current);
@@ -590,8 +644,8 @@ export const Controls: FC<Props> = ({
icon={isPlaying ? "pause" : "play"}
onPress={handlePlayPauseButton}
disabled={false}
hasTVPreferredFocus={!false && lastOpenedModal === null}
size={36}
refSetter={setPlayButtonRef}
/>
<TVControlButton
icon='play-forward'
@@ -639,26 +693,34 @@ export const Controls: FC<Props> = ({
</View>
)}
<View style={styles.progressBarContainer} pointerEvents='none'>
<View style={styles.progressTrack}>
<Animated.View
style={[
styles.cacheProgress,
useAnimatedStyle(() => ({
width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`,
})),
]}
/>
<Animated.View
style={[
styles.progressFill,
useAnimatedStyle(() => ({
width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`,
})),
]}
/>
</View>
</View>
{/* Bidirectional focus guides - stacked together per docs */}
{/* Downward: play button → progress bar */}
{progressBarRef && (
<TVFocusGuideView
destinations={[progressBarRef]}
style={styles.focusGuide}
/>
)}
{/* Upward: progress bar → play button */}
{playButtonRef && (
<TVFocusGuideView
destinations={[playButtonRef]}
style={styles.focusGuide}
/>
)}
{/* Progress bar with focus trapping for left/right */}
<TVFocusGuideView trapFocusLeft trapFocusRight>
<TVFocusableProgressBar
progress={effectiveProgress}
max={max}
cacheProgress={cacheProgress}
onFocus={() => setIsProgressBarFocused(true)}
onBlur={() => setIsProgressBarFocused(false)}
refSetter={setProgressBarRef}
hasTVPreferredFocus={lastOpenedModal === null}
/>
</TVFocusGuideView>
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
@@ -735,6 +797,10 @@ const styles = StyleSheet.create({
alignItems: "center",
zIndex: 20,
},
focusGuide: {
height: 1,
width: "100%",
},
progressBarContainer: {
height: TV_SEEKBAR_HEIGHT,
justifyContent: "center",

View File

@@ -23,6 +23,14 @@ interface UseRemoteControlProps {
disableSeeking?: boolean;
/** Callback for back/menu button press (tvOS: menu, Android TV: back) */
onBack?: () => void;
/** Whether the progress bar currently has focus */
isProgressBarFocused?: boolean;
/** Callback for seeking left when progress bar is focused */
onSeekLeft?: () => void;
/** Callback for seeking right when progress bar is focused */
onSeekRight?: () => void;
/** Callback for any interaction that should reset the controls timeout */
onInteraction?: () => void;
// Legacy props - kept for backwards compatibility with mobile Controls.tsx
// These are ignored in the simplified implementation
progress?: SharedValue<number>;
@@ -49,6 +57,10 @@ export function useRemoteControl({
toggleControls,
togglePlay,
onBack,
isProgressBarFocused,
onSeekLeft,
onSeekRight,
onInteraction,
}: UseRemoteControlProps) {
// Keep these for backward compatibility with the component
const remoteScrubProgress = useSharedValue<number | null>(null);
@@ -74,12 +86,28 @@ export function useRemoteControl({
if (togglePlay) {
togglePlay();
}
onInteraction?.();
return;
}
// Show controls on any D-pad press
// Handle left/right D-pad seeking when progress bar is focused
if (isProgressBarFocused) {
if (evt.eventType === "left" && onSeekLeft) {
onSeekLeft();
return;
}
if (evt.eventType === "right" && onSeekRight) {
onSeekRight();
return;
}
}
// Show controls on any D-pad press, or reset timeout if already showing
if (!showControls) {
toggleControls();
} else {
// Reset the timeout on any D-pad navigation when controls are showing
onInteraction?.();
}
});