mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-21 02:28:08 +00:00
feat(tv): minimal seekbar
This commit is contained in:
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user