mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-24 21:12:23 +00:00
feat: seekbar left/right actions
This commit is contained in:
@@ -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}
|
||||
|
||||
137
components/tv/TVFocusableProgressBar.tsx
Normal file
137
components/tv/TVFocusableProgressBar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user