@8man/react-native-media-console
Version:
Controls for react-native-video
521 lines (502 loc) • 16 kB
JavaScript
import { View, Text, Pressable, Dimensions } from 'react-native';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import Animated, { useAnimatedStyle, withTiming, withSequence, useSharedValue, withDelay, runOnJS } from 'react-native-reanimated';
import Icon from '@expo/vector-icons/MaterialIcons';
import * as Brightness from 'expo-brightness';
import { VolumeManager } from 'react-native-volume-manager';
import { GestureDetector, Gesture, GestureHandlerRootView } from 'react-native-gesture-handler';
const SWIPE_RANGE = 370;
const Ripple = /*#__PURE__*/React.memo(({
visible,
isLeft,
totalTime,
showControls
}) => {
const screenDimensions = useMemo(() => Dimensions.get('window'), []);
const {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT
} = screenDimensions;
const scale = useSharedValue(0);
const opacity = useSharedValue(0);
React.useEffect(() => {
if (visible) {
scale.value = withSequence(withTiming(1.5, {
duration: 400
}), withDelay(400, withTiming(0, {
duration: 400
})));
opacity.value = withSequence(withTiming(0.4, {
duration: 400
}), withDelay(400, withTiming(0, {
duration: 400
})));
}
}, [visible, scale, opacity]);
const rippleStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
//@ts-ignore
transform: [{
scale: scale.value
}]
}), []);
const containerStyle = useMemo(() => ({
position: 'absolute',
top: showControls ? -70 : -45,
left: isLeft ? '-10%' : undefined,
right: isLeft ? undefined : '-10%',
width: SCREEN_WIDTH / 2.5,
height: SCREEN_HEIGHT,
zIndex: 999
}), [showControls, isLeft, SCREEN_WIDTH, SCREEN_HEIGHT]);
const innerStyle = useMemo(() => ({
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: 'rgba(0,0,0,0.9)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: SCREEN_HEIGHT / 2
}), [SCREEN_HEIGHT]);
const textStyle = useMemo(() => ({
color: 'white',
marginTop: 8,
fontSize: 12
}), []);
return visible ? /*#__PURE__*/React.createElement(View, {
style: containerStyle
}, /*#__PURE__*/React.createElement(Animated.View, {
style: [innerStyle, rippleStyle]
}, /*#__PURE__*/React.createElement(Icon, {
name: isLeft ? 'fast-rewind' : 'fast-forward',
size: 28,
color: "white"
}), !isNaN(totalTime) && totalTime > 0 && /*#__PURE__*/React.createElement(Text, {
style: textStyle
}, Math.floor(totalTime), "s"))) : null;
});
const Gestures = ({
forward,
rewind,
togglePlayPause,
toggleControls,
doubleTapTime,
tapActionTimeout,
tapAnywhereToPause,
rewindTime = 10,
showControls,
disableGesture,
setPlayback
}) => {
const [rippleVisible, setRippleVisible] = useState(false);
const [isLeftRipple, setIsLeftRipple] = useState(false);
const [totalSkipTime, setTotalSkipTime] = useState(0);
const [displayVolume, setDisplayVolume] = useState(0);
const [displayBrightness, setDisplayBrightness] = useState(0);
const [isVolumeVisible, setIsVolumeVisible] = useState(false);
const [isBrightnessVisible, setIsBrightnessVisible] = useState(false);
const [toastMessage, setToastMessage] = useState(null);
// Memoize screen dimensions
const screenDimensions = useMemo(() => Dimensions.get('window'), []);
const {
width: SCREEN_WIDTH
} = screenDimensions;
// Refs
const initialTapPosition = useRef({
x: 0,
y: 0
});
const isDoubleTapRef = useRef(false);
const currentSideRef = useRef(null);
const tapCountRef = useRef(0);
const skipTimeoutRef = useRef(null);
const lastTapTimeRef = useRef(0);
const originalSettings = useRef({
volume: 0,
brightness: 0
});
// Shared values
const volumeValue = useSharedValue(0);
const brightnessValue = useSharedValue(0);
const startVolume = useSharedValue(0);
const startBrightness = useSharedValue(0);
const toastOpacity = useSharedValue(0);
// Toast styles (inline, no Tailwind)
const toastAnimatedStyle = useAnimatedStyle(() => ({
opacity: toastOpacity.value,
transform: [{
translateY: (1 - toastOpacity.value) * -4 // subtle lift-in
}]
}), []);
const toastContainerStyle = useMemo(() => ({
position: 'absolute',
width: '100%',
top: 48,
// ~ top-12
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 8,
// px-2
zIndex: 1200
}), []);
const toastTextStyle = useMemo(() => ({
color: 'white',
// text-white
backgroundColor: 'rgba(0,0,0,0.5)',
// bg-black/50
padding: 8,
// p-2
borderRadius: 9999,
// rounded-full
fontSize: 16 // text-base
}), []);
const show2xToast = useCallback(() => {
setToastMessage('2× speed');
toastOpacity.value = withTiming(1, {
duration: 150
});
}, [toastOpacity]);
const hideToast = useCallback(() => {
toastOpacity.value = withTiming(0, {
duration: 150
}, finished => {
if (finished) {
runOnJS(setToastMessage)(null);
}
});
}, [toastOpacity]);
const resetState = useCallback(() => {
isDoubleTapRef.current = false;
currentSideRef.current = null;
tapCountRef.current = 0;
lastTapTimeRef.current = 0;
setTotalSkipTime(0);
setRippleVisible(false);
if (skipTimeoutRef.current) {
clearTimeout(skipTimeoutRef.current);
skipTimeoutRef.current = null;
}
}, []);
const handleSkip = useCallback(async () => {
try {
const count = Number(tapCountRef.current) - 1;
const baseTime = Number(rewindTime);
const skipTime = baseTime * count;
if (!isNaN(skipTime) && skipTime > 0) {
if (currentSideRef.current === 'left') {
rewind(skipTime);
} else if (currentSideRef.current === 'right') {
forward(skipTime);
}
}
} catch (error) {
console.error('Error while skipping:', error);
} finally {
resetState();
}
}, [rewindTime, rewind, forward, resetState]);
const handleTap = useCallback((e, side) => {
const now = Date.now();
const touchX = e.nativeEvent.locationX;
const touchY = e.nativeEvent.locationY;
if (now - lastTapTimeRef.current > 500) {
resetState();
}
if (!isDoubleTapRef.current) {
isDoubleTapRef.current = true;
initialTapPosition.current = {
x: touchX,
y: touchY
};
currentSideRef.current = side;
tapCountRef.current = 1;
lastTapTimeRef.current = now;
tapActionTimeout.current = setTimeout(() => {
if (tapAnywhereToPause) {
togglePlayPause();
} else {
toggleControls();
}
resetState();
}, doubleTapTime);
} else {
if (tapActionTimeout.current) {
clearTimeout(tapActionTimeout.current);
tapActionTimeout.current = null;
}
if (currentSideRef.current === side) {
tapCountRef.current += 1;
lastTapTimeRef.current = now;
const count = Number(tapCountRef.current) - 1;
const baseTime = Number(rewindTime);
const newSkipTime = baseTime * count;
setTotalSkipTime(newSkipTime);
setRippleVisible(true);
setIsLeftRipple(side === 'left');
if (skipTimeoutRef.current) {
clearTimeout(skipTimeoutRef.current);
}
skipTimeoutRef.current = setTimeout(handleSkip, 500);
} else {
resetState();
isDoubleTapRef.current = true;
initialTapPosition.current = {
x: touchX,
y: touchY
};
currentSideRef.current = side;
tapCountRef.current = 1;
lastTapTimeRef.current = now;
tapActionTimeout.current = setTimeout(() => {
resetState();
}, doubleTapTime);
}
}
}, [resetState, tapAnywhereToPause, togglePlayPause, toggleControls, doubleTapTime, rewindTime, handleSkip]);
const updateSystemVolume = useCallback(newVolume => {
const clampedVolume = Math.max(0, Math.min(1, newVolume));
VolumeManager.setVolume(clampedVolume);
setDisplayVolume(clampedVolume);
}, []);
const updateSystemBrightness = useCallback(newBrightness => {
const clampedBrightness = Math.max(0, Math.min(1, newBrightness));
Brightness.setBrightnessAsync(clampedBrightness);
setDisplayBrightness(clampedBrightness);
}, []);
const panGesture = useMemo(() => Gesture.Pan().minDistance(10) // Minimum distance before gesture starts
.onStart(event => {
'worklet';
const isLeftSide = event.x < SCREEN_WIDTH / 2;
if (isLeftSide) {
startBrightness.value = brightnessValue.value;
runOnJS(setIsBrightnessVisible)(true);
} else {
startVolume.value = volumeValue.value;
runOnJS(setIsVolumeVisible)(true);
}
}).onUpdate(event => {
'worklet';
const isLeftSide = event.x < SCREEN_WIDTH / 2;
const change = -event.translationY / SWIPE_RANGE;
if (isLeftSide) {
// Brightness control
const newBrightness = Math.max(0, Math.min(1, startBrightness.value + change));
brightnessValue.value = newBrightness;
runOnJS(updateSystemBrightness)(newBrightness);
} else {
// Volume control
const newVolume = Math.max(0, Math.min(1, startVolume.value + change));
volumeValue.value = newVolume;
runOnJS(updateSystemVolume)(newVolume);
}
}).onFinalize(() => {
'worklet';
runOnJS(setIsVolumeVisible)(false);
runOnJS(setIsBrightnessVisible)(false);
}), [SCREEN_WIDTH, updateSystemBrightness, updateSystemVolume]);
const ControlOverlay = /*#__PURE__*/React.memo(({
value,
isVisible,
isVolume
}) => {
const containerStyle = useMemo(() => ({
position: 'absolute',
top: '50%',
left: !isVolume ? undefined : '15%',
right: !isVolume ? '15%' : undefined,
transform: [{
translateX: 0
}, {
translateY: showControls ? -20 : 0
}],
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 10,
minWidth: 50,
padding: 10,
alignItems: 'center',
zIndex: 1000
}), [isVolume, showControls]);
const textStyle = useMemo(() => ({
color: 'white',
marginTop: 5
}), []);
const iconName = useMemo(() => {
if (isVolume) {
return value === 0 ? 'volume-mute' : value < 0.3 ? 'volume-down' : 'volume-up';
}
return 'brightness-6';
}, [isVolume, value]);
if (!isVisible) return null;
return /*#__PURE__*/React.createElement(Animated.View, {
style: containerStyle
}, /*#__PURE__*/React.createElement(Icon, {
name: iconName,
size: 24,
color: "white"
}), /*#__PURE__*/React.createElement(Text, {
style: textStyle
}, Math.round(value * 100)));
});
// Initialize and store original settings
useEffect(() => {
let mounted = true;
const initializeSettings = async () => {
try {
const [currentVolume, currentBrightness] = await Promise.all([VolumeManager.getVolume(), Brightness.getBrightnessAsync()]);
if (mounted) {
// Store original values
originalSettings.current = {
volume: currentVolume.volume,
brightness: currentBrightness
};
// Set initial values
volumeValue.value = currentVolume.volume;
brightnessValue.value = currentBrightness;
setDisplayVolume(currentVolume.volume);
setDisplayBrightness(currentBrightness);
console.log('Original settings stored:🔥', {
volume: currentVolume,
brightness: currentBrightness
});
}
} catch (error) {
console.error('Error initializing settings:', error);
}
};
initializeSettings();
return () => {
mounted = false;
};
}, []);
// Cleanup useEffect
useEffect(() => {
return () => {
if (skipTimeoutRef.current) {
clearTimeout(skipTimeoutRef.current);
}
if (tapActionTimeout.current) {
clearTimeout(tapActionTimeout.current);
}
};
}, []);
// Initialize and store original settings
useEffect(() => {
let mounted = true;
const initializeSettings = async () => {
try {
const [currentVolume, currentBrightness] = await Promise.all([VolumeManager.getVolume(), Brightness.getBrightnessAsync()]);
if (mounted) {
// Store original values
originalSettings.current = {
volume: currentVolume.volume,
brightness: currentBrightness
};
// Set initial values
volumeValue.value = currentVolume.volume;
brightnessValue.value = currentBrightness;
setDisplayVolume(currentVolume.volume);
setDisplayBrightness(currentBrightness);
}
} catch (error) {
console.error('Error initializing settings:', error);
}
};
initializeSettings();
// Cleanup function
// return () => {
// mounted = false;
// const resetSettings = async () => {
// try {
// // console.log('Resetting to original settings:🔥', originalSettings.current);
// await Promise.all([
// // SystemSetting.setVolume(originalSettings.current.volume),
// SystemSetting.setAppBrightness(originalSettings.current.brightness),
// ]);
// // console.log('Settings reset successfully');
// } catch (error) {
// console.error('Error resetting settings:', error);
// }
// };
// resetSettings();
// };
}, []);
// Memoize container styles
const containerStyle = useMemo(() => ({
width: '100%',
height: '70%'
}), []);
const gestureContainerStyle = useMemo(() => ({
position: 'relative',
width: '100%',
height: '100%',
flexDirection: 'row'
}), []);
const leftPressableStyle = useMemo(() => ({
flex: 1,
top: 40,
height: '100%',
position: 'relative'
}), []);
const rightPressableStyle = useMemo(() => ({
top: 40,
flex: 1,
height: '100%',
position: 'relative'
}), []);
// Memoized handler functions
const handleLeftTap = useCallback(e => {
handleTap(e, 'left');
}, [handleTap]);
const handleRightTap = useCallback(e => {
handleTap(e, 'right');
}, [handleTap]);
if (disableGesture) {
return null;
}
return /*#__PURE__*/React.createElement(GestureHandlerRootView, {
style: containerStyle
}, /*#__PURE__*/React.createElement(GestureDetector, {
gesture: panGesture
}, /*#__PURE__*/React.createElement(View, {
style: gestureContainerStyle
}, /*#__PURE__*/React.createElement(Pressable, {
onPress: handleLeftTap,
style: leftPressableStyle
}, /*#__PURE__*/React.createElement(Ripple, {
visible: rippleVisible && isLeftRipple,
showControls: showControls,
isLeft: true,
totalTime: totalSkipTime
})), /*#__PURE__*/React.createElement(Pressable, {
onPress: handleRightTap,
style: rightPressableStyle,
onLongPress: () => {
setPlayback(2);
show2xToast();
},
onPressOut: () => {
setPlayback(1);
hideToast();
}
}, /*#__PURE__*/React.createElement(Ripple, {
visible: rippleVisible && !isLeftRipple,
showControls: showControls,
isLeft: false,
totalTime: totalSkipTime
})))), toastMessage ? /*#__PURE__*/React.createElement(Animated.View, {
style: [toastContainerStyle, toastAnimatedStyle],
pointerEvents: "none"
}, /*#__PURE__*/React.createElement(Text, {
style: toastTextStyle
}, toastMessage)) : null, /*#__PURE__*/React.createElement(ControlOverlay, {
value: displayVolume,
isVisible: isVolumeVisible,
isVolume: true
}), /*#__PURE__*/React.createElement(ControlOverlay, {
value: displayBrightness,
isVisible: isBrightnessVisible,
isVolume: false
}));
};
export default Gestures;
//# sourceMappingURL=Gestures.js.map