mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
improved gesture handler behaviour
This commit is contained in:
parent
a318bd350b
commit
fd9cc1ac52
4 changed files with 78 additions and 891 deletions
|
|
@ -1,464 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Animated } from 'react-native';
|
||||
import {
|
||||
TapGestureHandler,
|
||||
PanGestureHandler,
|
||||
LongPressGestureHandler,
|
||||
State
|
||||
} from 'react-native-gesture-handler';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { styles as localStyles } from '../../utils/playerStyles';
|
||||
|
||||
interface GestureControlsProps {
|
||||
screenDimensions: { width: number, height: number };
|
||||
gestureControls: any;
|
||||
onLongPressActivated: () => void;
|
||||
onLongPressEnd: () => void;
|
||||
onLongPressStateChange: (event: any) => void;
|
||||
toggleControls: () => void;
|
||||
showControls: boolean;
|
||||
hideControls: () => void;
|
||||
volume: number;
|
||||
brightness: number;
|
||||
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
resizeMode?: string;
|
||||
// New props for double-tap skip and horizontal seek
|
||||
skip?: (seconds: number) => void;
|
||||
currentTime?: number;
|
||||
duration?: number;
|
||||
seekToTime?: (seconds: number) => void;
|
||||
formatTime?: (seconds: number) => string;
|
||||
}
|
||||
|
||||
export const GestureControls: React.FC<GestureControlsProps> = ({
|
||||
screenDimensions,
|
||||
gestureControls,
|
||||
onLongPressActivated,
|
||||
onLongPressEnd,
|
||||
onLongPressStateChange,
|
||||
toggleControls,
|
||||
showControls,
|
||||
hideControls,
|
||||
volume,
|
||||
brightness,
|
||||
controlsTimeout,
|
||||
resizeMode = 'contain',
|
||||
skip,
|
||||
currentTime,
|
||||
duration,
|
||||
seekToTime,
|
||||
formatTime,
|
||||
}) => {
|
||||
|
||||
const getVolumeIcon = (value: number) => {
|
||||
if (value === 0) return 'volume-off';
|
||||
if (value < 0.3) return 'volume-mute';
|
||||
if (value < 0.6) return 'volume-down';
|
||||
return 'volume-up';
|
||||
};
|
||||
|
||||
const getBrightnessIcon = (value: number) => {
|
||||
if (value < 0.3) return 'brightness-low';
|
||||
if (value < 0.7) return 'brightness-medium';
|
||||
return 'brightness-high';
|
||||
};
|
||||
|
||||
// Refs for gesture handlers
|
||||
const leftDoubleTapRef = React.useRef(null);
|
||||
const rightDoubleTapRef = React.useRef(null);
|
||||
const horizontalSeekPanRef = React.useRef(null);
|
||||
const leftVerticalPanRef = React.useRef(null);
|
||||
const rightVerticalPanRef = React.useRef(null);
|
||||
|
||||
// State for double-tap skip overlays
|
||||
const [showSkipForwardOverlay, setShowSkipForwardOverlay] = useState(false);
|
||||
const [showSkipBackwardOverlay, setShowSkipBackwardOverlay] = useState(false);
|
||||
const [skipAmount, setSkipAmount] = useState(10);
|
||||
|
||||
// State for horizontal seek
|
||||
const [isHorizontalSeeking, setIsHorizontalSeeking] = useState(false);
|
||||
const [seekPreviewTime, setSeekPreviewTime] = useState(0);
|
||||
const [seekStartTime, setSeekStartTime] = useState(0);
|
||||
|
||||
// Refs for overlay timeouts
|
||||
const skipForwardTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const skipBackwardTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (skipForwardTimeoutRef.current) clearTimeout(skipForwardTimeoutRef.current);
|
||||
if (skipBackwardTimeoutRef.current) clearTimeout(skipBackwardTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Double-tap handlers
|
||||
const handleLeftDoubleTap = () => {
|
||||
if (skip) {
|
||||
skip(-10);
|
||||
setSkipAmount(prev => {
|
||||
const newAmount = showSkipBackwardOverlay ? prev + 10 : 10;
|
||||
return newAmount;
|
||||
});
|
||||
setShowSkipBackwardOverlay(true);
|
||||
if (skipBackwardTimeoutRef.current) {
|
||||
clearTimeout(skipBackwardTimeoutRef.current);
|
||||
}
|
||||
skipBackwardTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipBackwardOverlay(false);
|
||||
setSkipAmount(10);
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightDoubleTap = () => {
|
||||
if (skip) {
|
||||
skip(10);
|
||||
setSkipAmount(prev => {
|
||||
const newAmount = showSkipForwardOverlay ? prev + 10 : 10;
|
||||
return newAmount;
|
||||
});
|
||||
setShowSkipForwardOverlay(true);
|
||||
if (skipForwardTimeoutRef.current) {
|
||||
clearTimeout(skipForwardTimeoutRef.current);
|
||||
}
|
||||
skipForwardTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipForwardOverlay(false);
|
||||
setSkipAmount(10);
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
// Shared styles for gesture areas (relative to parent container)
|
||||
const leftSideStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: '100%' as const,
|
||||
};
|
||||
|
||||
const rightSideStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: '100%' as const,
|
||||
};
|
||||
|
||||
// Full gesture area style
|
||||
const gestureAreaStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Horizontal seek gesture - OUTERMOST wrapper, fails on vertical movement */}
|
||||
<PanGestureHandler
|
||||
ref={horizontalSeekPanRef}
|
||||
onGestureEvent={(event: any) => {
|
||||
const { translationX, state } = event.nativeEvent;
|
||||
|
||||
if (state === State.ACTIVE) {
|
||||
if (!isHorizontalSeeking && currentTime !== undefined) {
|
||||
setIsHorizontalSeeking(true);
|
||||
setSeekStartTime(currentTime);
|
||||
}
|
||||
|
||||
if (duration && duration > 0) {
|
||||
const sensitivityFactor = duration > 3600 ? 120 : duration > 1800 ? 90 : 60;
|
||||
const seekDelta = (translationX / screenDimensions.width) * sensitivityFactor;
|
||||
const newTime = Math.max(0, Math.min(duration, seekStartTime + seekDelta));
|
||||
setSeekPreviewTime(newTime);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onHandlerStateChange={(event: any) => {
|
||||
const { state } = event.nativeEvent;
|
||||
|
||||
if (state === State.END || state === State.CANCELLED) {
|
||||
if (isHorizontalSeeking && seekToTime) {
|
||||
seekToTime(seekPreviewTime);
|
||||
}
|
||||
setIsHorizontalSeeking(false);
|
||||
}
|
||||
}}
|
||||
activeOffsetX={[-30, 30]}
|
||||
failOffsetY={[-20, 20]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<View style={gestureAreaStyle}>
|
||||
{/* Left side gestures */}
|
||||
<TapGestureHandler
|
||||
ref={leftDoubleTapRef}
|
||||
numberOfTaps={2}
|
||||
onActivated={handleLeftDoubleTap}
|
||||
>
|
||||
<View style={leftSideStyle}>
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<PanGestureHandler
|
||||
ref={leftVerticalPanRef}
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-20, 20]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<TapGestureHandler
|
||||
waitFor={leftDoubleTapRef}
|
||||
onActivated={toggleControls}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
</TapGestureHandler>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
</View>
|
||||
</LongPressGestureHandler>
|
||||
</View>
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: '100%',
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Right side gestures */}
|
||||
<TapGestureHandler
|
||||
ref={rightDoubleTapRef}
|
||||
numberOfTaps={2}
|
||||
onActivated={handleRightDoubleTap}
|
||||
>
|
||||
<View style={rightSideStyle}>
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<PanGestureHandler
|
||||
ref={rightVerticalPanRef}
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-20, 20]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<TapGestureHandler
|
||||
waitFor={rightDoubleTapRef}
|
||||
onActivated={toggleControls}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
</TapGestureHandler>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
</View>
|
||||
</LongPressGestureHandler>
|
||||
</View>
|
||||
</TapGestureHandler>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
|
||||
{/* Volume/Brightness Pill Overlay */}
|
||||
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={localStyles.gestureIndicatorPill}>
|
||||
<View
|
||||
style={[
|
||||
localStyles.iconWrapper,
|
||||
{
|
||||
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(242, 184, 181)'
|
||||
: 'rgba(59, 59, 59)'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={
|
||||
gestureControls.showVolumeOverlay
|
||||
? getVolumeIcon(volume)
|
||||
: getBrightnessIcon(brightness)
|
||||
}
|
||||
size={24}
|
||||
color={
|
||||
gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(96, 20, 16)'
|
||||
: 'rgba(255, 255, 255)'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
localStyles.gestureText,
|
||||
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' }
|
||||
]}
|
||||
>
|
||||
{gestureControls.showVolumeOverlay && volume === 0
|
||||
? "Muted"
|
||||
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%`
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Aspect Ratio Overlay */}
|
||||
{gestureControls.showResizeModeOverlay && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
localStyles.gestureIndicatorPill,
|
||||
{ opacity: gestureControls.resizeModeOverlayOpacity }
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
localStyles.iconWrapper,
|
||||
{
|
||||
backgroundColor: 'rgba(59, 59, 59)'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="aspect-ratio"
|
||||
size={24}
|
||||
color="rgba(255, 255, 255)"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={localStyles.gestureText}
|
||||
>
|
||||
{resizeMode.charAt(0).toUpperCase() + resizeMode.slice(1)}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Skip Forward Overlay - Right side */}
|
||||
{showSkipForwardOverlay && (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
right: screenDimensions.width * 0.1,
|
||||
top: screenDimensions.height * 0.35,
|
||||
height: screenDimensions.height * 0.3,
|
||||
width: screenDimensions.width * 0.25,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 2000,
|
||||
}}>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
}} />
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 4,
|
||||
}}>
|
||||
+{skipAmount}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Skip Backward Overlay - Left side */}
|
||||
{showSkipBackwardOverlay && (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
left: screenDimensions.width * 0.1,
|
||||
top: screenDimensions.height * 0.35,
|
||||
height: screenDimensions.height * 0.3,
|
||||
width: screenDimensions.width * 0.25,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 2000,
|
||||
}}>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
}} />
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<MaterialIcons name="fast-rewind" size={32} color="#FFFFFF" />
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 4,
|
||||
}}>
|
||||
-{skipAmount}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Horizontal Seek Preview Overlay */}
|
||||
{isHorizontalSeeking && formatTime && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={localStyles.gestureIndicatorPill}>
|
||||
<View style={[localStyles.iconWrapper, { backgroundColor: 'rgba(59, 59, 59)' }]}>
|
||||
<MaterialIcons
|
||||
name={seekPreviewTime > (currentTime || 0) ? "fast-forward" : "fast-rewind"}
|
||||
size={24}
|
||||
color="rgba(255, 255, 255)"
|
||||
/>
|
||||
</View>
|
||||
<Text style={localStyles.gestureText}>
|
||||
{formatTime(seekPreviewTime)}
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: seekPreviewTime > (currentTime || 0) ? '#4CAF50' : '#FF5722',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 4,
|
||||
}}>
|
||||
{seekPreviewTime > (currentTime || 0) ? '+' : ''}
|
||||
{Math.round(seekPreviewTime - (currentTime || 0))}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -92,40 +92,85 @@ export const GestureControls: React.FC<GestureControlsProps> = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Refs for tracking rapid seek state
|
||||
const seekBaselineTime = React.useRef<number | null>(null);
|
||||
const gestureSkipAmount = React.useRef(0);
|
||||
|
||||
// Double-tap handlers
|
||||
const handleLeftDoubleTap = () => {
|
||||
if (skip) {
|
||||
skip(-10);
|
||||
setSkipAmount(prev => {
|
||||
const newAmount = showSkipBackwardOverlay ? prev + 10 : 10;
|
||||
return newAmount;
|
||||
});
|
||||
if (seekToTime && currentTime !== undefined) {
|
||||
// If overlay is not visible, this is a new seek sequence
|
||||
if (!showSkipBackwardOverlay) {
|
||||
seekBaselineTime.current = currentTime;
|
||||
gestureSkipAmount.current = 0;
|
||||
}
|
||||
|
||||
// Increment skip amount
|
||||
gestureSkipAmount.current += 10;
|
||||
const currentSkip = gestureSkipAmount.current;
|
||||
|
||||
// Calculate target time based on locked baseline
|
||||
const baseTime = seekBaselineTime.current !== null ? seekBaselineTime.current : currentTime;
|
||||
const targetTime = Math.max(0, baseTime - currentSkip);
|
||||
|
||||
// Execute seek
|
||||
seekToTime(targetTime);
|
||||
|
||||
// Update UI state
|
||||
setSkipAmount(currentSkip);
|
||||
setShowSkipBackwardOverlay(true);
|
||||
|
||||
if (skipBackwardTimeoutRef.current) {
|
||||
clearTimeout(skipBackwardTimeoutRef.current);
|
||||
}
|
||||
skipBackwardTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipBackwardOverlay(false);
|
||||
setSkipAmount(10);
|
||||
gestureSkipAmount.current = 0;
|
||||
seekBaselineTime.current = null;
|
||||
}, 800);
|
||||
} else if (skip) {
|
||||
// Fallback if seekToTime not available
|
||||
skip(-10);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightDoubleTap = () => {
|
||||
if (skip) {
|
||||
skip(10);
|
||||
setSkipAmount(prev => {
|
||||
const newAmount = showSkipForwardOverlay ? prev + 10 : 10;
|
||||
return newAmount;
|
||||
});
|
||||
if (seekToTime && currentTime !== undefined) {
|
||||
// If overlay is not visible, this is a new seek sequence
|
||||
if (!showSkipForwardOverlay) {
|
||||
seekBaselineTime.current = currentTime;
|
||||
gestureSkipAmount.current = 0;
|
||||
}
|
||||
|
||||
// Increment skip amount
|
||||
gestureSkipAmount.current += 10;
|
||||
const currentSkip = gestureSkipAmount.current;
|
||||
|
||||
// Calculate target time based on locked baseline
|
||||
const baseTime = seekBaselineTime.current !== null ? seekBaselineTime.current : currentTime;
|
||||
const targetTime = baseTime + currentSkip;
|
||||
// Note: duration check happens in seekToTime
|
||||
|
||||
// Execute seek
|
||||
seekToTime(targetTime);
|
||||
|
||||
// Update UI state
|
||||
setSkipAmount(currentSkip);
|
||||
setShowSkipForwardOverlay(true);
|
||||
|
||||
if (skipForwardTimeoutRef.current) {
|
||||
clearTimeout(skipForwardTimeoutRef.current);
|
||||
}
|
||||
skipForwardTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipForwardOverlay(false);
|
||||
setSkipAmount(10);
|
||||
gestureSkipAmount.current = 0;
|
||||
seekBaselineTime.current = null;
|
||||
}, 800);
|
||||
} else if (skip) {
|
||||
// Fallback
|
||||
skip(10);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -362,31 +407,12 @@ export const GestureControls: React.FC<GestureControlsProps> = ({
|
|||
|
||||
{/* Skip Forward Overlay - Right side */}
|
||||
{showSkipForwardOverlay && (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
right: screenDimensions.width * 0.1,
|
||||
top: screenDimensions.height * 0.35,
|
||||
height: screenDimensions.height * 0.3,
|
||||
width: screenDimensions.width * 0.25,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 2000,
|
||||
}}>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
}} />
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 4,
|
||||
}}>
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={localStyles.gestureIndicatorPill}>
|
||||
<View style={localStyles.iconWrapper}>
|
||||
<MaterialIcons name="fast-forward" size={18} color="rgba(255, 255, 255, 0.9)" />
|
||||
</View>
|
||||
<Text style={localStyles.gestureText}>
|
||||
+{skipAmount}s
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -395,31 +421,12 @@ export const GestureControls: React.FC<GestureControlsProps> = ({
|
|||
|
||||
{/* Skip Backward Overlay - Left side */}
|
||||
{showSkipBackwardOverlay && (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
left: screenDimensions.width * 0.1,
|
||||
top: screenDimensions.height * 0.35,
|
||||
height: screenDimensions.height * 0.3,
|
||||
width: screenDimensions.width * 0.25,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 2000,
|
||||
}}>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
}} />
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<MaterialIcons name="fast-rewind" size={32} color="#FFFFFF" />
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 4,
|
||||
}}>
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={localStyles.gestureIndicatorPill}>
|
||||
<View style={localStyles.iconWrapper}>
|
||||
<MaterialIcons name="fast-rewind" size={18} color="rgba(255, 255, 255, 0.9)" />
|
||||
</View>
|
||||
<Text style={localStyles.gestureText}>
|
||||
-{skipAmount}s
|
||||
</Text>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -37,30 +37,32 @@ export const usePlayerControls = (config: PlayerControlsConfig) => {
|
|||
setPaused(!paused);
|
||||
}, [paused, setPaused]);
|
||||
|
||||
const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const seekToTime = useCallback((rawSeconds: number) => {
|
||||
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
|
||||
|
||||
if (playerRef.current && duration > 0 && !isSeeking.current) {
|
||||
if (playerRef.current && duration > 0) {
|
||||
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
|
||||
|
||||
isSeeking.current = true;
|
||||
|
||||
// iOS optimization: pause while seeking for smoother experience
|
||||
|
||||
// Clear existing timeout to keep isSeeking true during rapid seeks
|
||||
if (seekTimeoutRef.current) {
|
||||
clearTimeout(seekTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Actually perform the seek
|
||||
playerRef.current.seek(timeInSeconds);
|
||||
|
||||
// Debounce the seeking state reset
|
||||
setTimeout(() => {
|
||||
seekTimeoutRef.current = setTimeout(() => {
|
||||
if (isMounted.current && isSeeking.current) {
|
||||
isSeeking.current = false;
|
||||
// Resume if it was playing (iOS specific)
|
||||
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [duration, paused, setPaused, playerRef, isSeeking, isMounted]);
|
||||
}, [duration, paused, playerRef, isSeeking, isMounted]);
|
||||
|
||||
const skip = useCallback((seconds: number) => {
|
||||
seekToTime(currentTime + seconds);
|
||||
|
|
|
|||
|
|
@ -1,358 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Animated } from 'react-native';
|
||||
import {
|
||||
TapGestureHandler,
|
||||
PanGestureHandler,
|
||||
LongPressGestureHandler,
|
||||
} from 'react-native-gesture-handler';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
||||
interface GestureControlsProps {
|
||||
screenDimensions: { width: number, height: number };
|
||||
gestureControls: any;
|
||||
onLongPressActivated: () => void;
|
||||
onLongPressEnd: () => void;
|
||||
onLongPressStateChange: (event: any) => void;
|
||||
toggleControls: () => void;
|
||||
showControls: boolean;
|
||||
hideControls: () => void;
|
||||
volume: number;
|
||||
brightness: number;
|
||||
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
}
|
||||
|
||||
export const GestureControls: React.FC<GestureControlsProps> = ({
|
||||
screenDimensions,
|
||||
gestureControls,
|
||||
onLongPressActivated,
|
||||
onLongPressEnd,
|
||||
onLongPressStateChange,
|
||||
toggleControls,
|
||||
showControls,
|
||||
hideControls,
|
||||
volume,
|
||||
brightness,
|
||||
controlsTimeout
|
||||
}) => {
|
||||
// Helper to get dimensions (using passed screenDimensions)
|
||||
const getDimensions = () => screenDimensions;
|
||||
|
||||
// Create refs for all gesture handlers to enable cross-referencing
|
||||
const leftPanRef = React.useRef(null);
|
||||
const rightPanRef = React.useRef(null);
|
||||
const leftTapRef = React.useRef(null);
|
||||
const rightTapRef = React.useRef(null);
|
||||
const centerTapRef = React.useRef(null);
|
||||
const leftLongPressRef = React.useRef(null);
|
||||
const rightLongPressRef = React.useRef(null);
|
||||
|
||||
// Shared style for left side gesture area
|
||||
const leftSideStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
// Shared style for right side gesture area
|
||||
const rightSideStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: screenDimensions.height * 0.15,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left side gestures - brightness + tap + long press (flat structure) */}
|
||||
<LongPressGestureHandler
|
||||
ref={leftLongPressRef}
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
>
|
||||
<View style={leftSideStyle} />
|
||||
</LongPressGestureHandler>
|
||||
|
||||
<PanGestureHandler
|
||||
ref={leftPanRef}
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-15, 15]}
|
||||
failOffsetX={[-60, 60]}
|
||||
shouldCancelWhenOutside={false}
|
||||
maxPointers={1}
|
||||
>
|
||||
<View style={leftSideStyle} />
|
||||
</PanGestureHandler>
|
||||
|
||||
<TapGestureHandler
|
||||
ref={leftTapRef}
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
waitFor={[leftPanRef, leftLongPressRef]}
|
||||
>
|
||||
<View style={leftSideStyle} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Right side gestures - volume + tap + long press (flat structure) */}
|
||||
<LongPressGestureHandler
|
||||
ref={rightLongPressRef}
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
>
|
||||
<View style={rightSideStyle} />
|
||||
</LongPressGestureHandler>
|
||||
|
||||
<PanGestureHandler
|
||||
ref={rightPanRef}
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-15, 15]}
|
||||
failOffsetX={[-60, 60]}
|
||||
shouldCancelWhenOutside={false}
|
||||
maxPointers={1}
|
||||
>
|
||||
<View style={rightSideStyle} />
|
||||
</PanGestureHandler>
|
||||
|
||||
<TapGestureHandler
|
||||
ref={rightTapRef}
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
waitFor={[rightPanRef, rightLongPressRef]}
|
||||
>
|
||||
<View style={rightSideStyle} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
ref={centerTapRef}
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
shouldCancelWhenOutside={false}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Volume Overlay */}
|
||||
{gestureControls.showVolumeOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: getDimensions().width / 2 - 60,
|
||||
top: getDimensions().height / 2 - 60,
|
||||
opacity: gestureControls.volumeOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<MaterialIcons
|
||||
name={volume === 0 ? "volume-off" : volume < 30 ? "volume-mute" : volume < 70 ? "volume-down" : "volume-up"}
|
||||
size={24}
|
||||
color={volume === 0 ? "#FF6B6B" : "#FFFFFF"}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Horizontal Dotted Progress Bar */}
|
||||
<View style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* Dotted background */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 1,
|
||||
}}>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 0.75,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Progress fill */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${volume}%`,
|
||||
height: 6,
|
||||
backgroundColor: volume === 0 ? '#FF6B6B' : '#E50914',
|
||||
borderRadius: 3,
|
||||
shadowColor: volume === 0 ? '#FF6B6B' : '#E50914',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 2,
|
||||
}} />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{Math.round(volume)}%
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Brightness Overlay */}
|
||||
{gestureControls.showBrightnessOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: getDimensions().width / 2 - 60,
|
||||
top: getDimensions().height / 2 - 60,
|
||||
opacity: gestureControls.brightnessOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<MaterialIcons
|
||||
name={brightness < 0.2 ? "brightness-low" : brightness < 0.5 ? "brightness-medium" : brightness < 0.8 ? "brightness-high" : "brightness-auto"}
|
||||
size={24}
|
||||
color={brightness < 0.2 ? "#FFD700" : "#FFFFFF"}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Horizontal Dotted Progress Bar */}
|
||||
<View style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* Dotted background */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 1,
|
||||
}}>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 0.75,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Progress fill */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${brightness * 100}%`,
|
||||
height: 6,
|
||||
backgroundColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
|
||||
borderRadius: 3,
|
||||
shadowColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 2,
|
||||
}} />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{Math.round(brightness * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue