From aa6b441dd1594d84db3cb1630ceed4c916a4be76 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH] feat(tv): minimal seekbar --- .../video-player/controls/Controls.tv.tsx | 187 ++++++++++++++++++ .../controls/hooks/useRemoteControl.ts | 33 +++- 2 files changed, 212 insertions(+), 8 deletions(-) diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index afdb9bdb..b5ac3364 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -134,6 +134,13 @@ export const Controls: FC = ({ const [playButtonRef, setPlayButtonRef] = useState(null); const [progressBarRef, setProgressBarRef] = useState(null); + // Minimal seek bar state (shows only progress bar when seeking while controls hidden) + const [showMinimalSeekBar, setShowMinimalSeekBar] = useState(false); + const minimalSeekBarOpacity = useSharedValue(0); + const minimalSeekBarTimeoutRef = useRef | null>( + null, + ); + const audioTracks = useMemo(() => { return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; }, [mediaSource]); @@ -200,6 +207,22 @@ export const Controls: FC = ({ transform: [{ translateY: bottomTranslateY.value }], })); + // Minimal seek bar animation + useEffect(() => { + const animationConfig = { + duration: 200, + easing: Easing.out(Easing.quad), + }; + minimalSeekBarOpacity.value = withTiming( + showMinimalSeekBar ? 1 : 0, + animationConfig, + ); + }, [showMinimalSeekBar, minimalSeekBarOpacity]); + + const minimalSeekBarAnimatedStyle = useAnimatedStyle(() => ({ + opacity: minimalSeekBarOpacity.value, + })); + useEffect(() => { if (item) { progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks); @@ -255,6 +278,31 @@ export const Controls: FC = ({ // No longer needed since modals are screen-based }, []); + // Show minimal seek bar (only progress bar, no buttons) + const showMinimalSeek = useCallback(() => { + setShowMinimalSeekBar(true); + + // Clear existing timeout + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + + // Auto-hide after timeout + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 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({ @@ -395,6 +443,61 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + // Minimal seek mode handlers (only show progress bar, not full controls) + const handleMinimalSeekRight = useCallback(() => { + const newPosition = Math.min(max.value, progress.value + 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + // Show minimal seek bar and reset its timeout + showMinimalSeek(); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + showMinimalSeek, + ]); + + const handleMinimalSeekLeft = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + // Show minimal seek bar and reset its timeout + showMinimalSeek(); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + }, [ + progress, + min, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + showMinimalSeek, + ]); + // Callback for remote interactions to reset timeout const handleRemoteInteraction = useCallback(() => { controlsInteractionRef.current(); @@ -408,6 +511,8 @@ export const Controls: FC = ({ isProgressBarFocused, onSeekLeft: handleProgressSeekLeft, onSeekRight: handleProgressSeekRight, + onMinimalSeekLeft: handleMinimalSeekLeft, + onMinimalSeekRight: handleMinimalSeekRight, onInteraction: handleRemoteInteraction, }); @@ -598,6 +703,63 @@ export const Controls: FC = ({ /> )} + {/* Minimal seek bar - shows only progress bar when seeking while controls hidden */} + + + {showSeekBubble && ( + + + + )} + + + + ({ + width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + ({ + width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + + + + + + {formatTimeString(currentTime, "ms")} + + + -{formatTimeString(remainingTime, "ms")} + + + + + void; /** Callback for seeking right when progress bar is focused */ onSeekRight?: () => void; + /** Callback for seeking left when controls are hidden (minimal seek mode) */ + onMinimalSeekLeft?: () => void; + /** Callback for seeking right when controls are hidden (minimal seek mode) */ + onMinimalSeekRight?: () => void; /** Callback for any interaction that should reset the controls timeout */ onInteraction?: () => void; // Legacy props - kept for backwards compatibility with mobile Controls.tsx @@ -60,6 +64,8 @@ export function useRemoteControl({ isProgressBarFocused, onSeekLeft, onSeekRight, + onMinimalSeekLeft, + onMinimalSeekRight, onInteraction, }: UseRemoteControlProps) { // Keep these for backward compatibility with the component @@ -90,7 +96,23 @@ export function useRemoteControl({ return; } - // Handle left/right D-pad seeking when progress bar is focused + // Handle left/right D-pad - check controls hidden state FIRST + if (!showControls) { + // Minimal seek mode when controls are hidden + if (evt.eventType === "left" && onMinimalSeekLeft) { + onMinimalSeekLeft(); + return; + } + if (evt.eventType === "right" && onMinimalSeekRight) { + onMinimalSeekRight(); + return; + } + // For other D-pad presses, show full controls + toggleControls(); + return; + } + + // Controls are showing - handle seeking when progress bar is focused if (isProgressBarFocused) { if (evt.eventType === "left" && onSeekLeft) { onSeekLeft(); @@ -102,13 +124,8 @@ export function useRemoteControl({ } } - // 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?.(); - } + // Reset the timeout on any D-pad navigation when controls are showing + onInteraction?.(); }); return {