feat(tv): minimal seekbar

This commit is contained in:
Fredrik Burmester
2026-01-20 22:15:00 +01:00
parent d8512897ad
commit aa6b441dd1
2 changed files with 212 additions and 8 deletions

View File

@@ -134,6 +134,13 @@ export const Controls: FC<Props> = ({
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [progressBarRef, setProgressBarRef] = useState<View | null>(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<ReturnType<typeof setTimeout> | null>(
null,
);
const audioTracks = useMemo(() => {
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
}, [mediaSource]);
@@ -200,6 +207,22 @@ export const Controls: FC<Props> = ({
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<Props> = ({
// 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<Props> = ({
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<Props> = ({
isProgressBarFocused,
onSeekLeft: handleProgressSeekLeft,
onSeekRight: handleProgressSeekRight,
onMinimalSeekLeft: handleMinimalSeekLeft,
onMinimalSeekRight: handleMinimalSeekRight,
onInteraction: handleRemoteInteraction,
});
@@ -598,6 +703,63 @@ export const Controls: FC<Props> = ({
/>
)}
{/* Minimal seek bar - shows only progress bar when seeking while controls hidden */}
<Animated.View
style={[styles.minimalSeekBarContainer, minimalSeekBarAnimatedStyle]}
pointerEvents={showMinimalSeekBar && !showControls ? "auto" : "none"}
>
<View
style={[
styles.minimalSeekBarInner,
{
paddingRight: Math.max(insets.right, 48),
paddingLeft: Math.max(insets.left, 48),
paddingBottom: Math.max(insets.bottom, 24),
},
]}
>
{showSeekBubble && (
<View style={styles.minimalTrickplayContainer}>
<TrickplayBubble
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={seekBubbleTime}
/>
</View>
)}
<View style={styles.minimalProgressContainer}>
<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>
<View style={styles.minimalTimeContainer}>
<Text style={styles.timeText}>
{formatTimeString(currentTime, "ms")}
</Text>
<Text style={styles.timeText}>
-{formatTimeString(remainingTime, "ms")}
</Text>
</View>
</View>
</Animated.View>
<Animated.View
style={[styles.bottomContainer, bottomAnimatedStyle]}
pointerEvents={showControls && !false ? "auto" : "none"}
@@ -847,4 +1009,29 @@ const styles = StyleSheet.create({
fontSize: 16,
marginTop: 2,
},
// Minimal seek bar styles
minimalSeekBarContainer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
zIndex: 5,
},
minimalSeekBarInner: {
flexDirection: "column",
},
minimalTrickplayContainer: {
alignItems: "center",
marginBottom: 16,
},
minimalProgressContainer: {
height: TV_SEEKBAR_HEIGHT,
justifyContent: "center",
marginBottom: 8,
},
minimalTimeContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
});

View File

@@ -29,6 +29,10 @@ interface UseRemoteControlProps {
onSeekLeft?: () => 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 {