mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
feat: Add responsive video player controls with animations
- Implement responsive sizing for all controls based on screen width (phone/tablet support) - Add smooth crossfade animation for play/pause button transitions - Add arc sweep and slide animations for seek buttons (+10/-10s) - Add touch feedback with semi-transparent circle flash on tap
This commit is contained in:
parent
23acda3167
commit
e305dee777
2 changed files with 402 additions and 41 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform } from 'react-native';
|
||||
import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform, Dimensions } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Slider from '@react-native-community/slider';
|
||||
|
|
@ -82,6 +82,156 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
playerBackend,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
||||
/* Responsive Spacing */
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
const buttonSpacing = screenWidth * 0.15;
|
||||
|
||||
const playButtonSize = screenWidth * 0.12; // 12% of screen width
|
||||
const playIconSize = playButtonSize * 0.6; // 60% of button size
|
||||
const seekButtonSize = screenWidth * 0.11; // 11% of screen width
|
||||
const seekIconSize = seekButtonSize * 0.75; // 75% of button size
|
||||
const seekNumberSize = seekButtonSize * 0.25; // 25% of button size
|
||||
const arcBorderWidth = seekButtonSize * 0.05; // 5% of button size
|
||||
|
||||
/* Animations - State & Refs */
|
||||
const [showBackwardSign, setShowBackwardSign] = React.useState(false);
|
||||
const [showForwardSign, setShowForwardSign] = React.useState(false);
|
||||
|
||||
/* Separate Animations for Each Button */
|
||||
const backwardPressAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const backwardSlideAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const backwardScaleAnim = React.useRef(new Animated.Value(1)).current;
|
||||
const backwardArcOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const backwardArcRotation = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const forwardPressAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const forwardSlideAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const forwardScaleAnim = React.useRef(new Animated.Value(1)).current;
|
||||
const forwardArcOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const forwardArcRotation = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const playPressAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const playIconScale = React.useRef(new Animated.Value(1)).current;
|
||||
const playIconOpacity = React.useRef(new Animated.Value(1)).current;
|
||||
|
||||
/* Handle Seek with Animation */
|
||||
const handleSeekWithAnimation = (seconds: number) => {
|
||||
const isForward = seconds > 0;
|
||||
|
||||
if (isForward) {
|
||||
setShowForwardSign(true);
|
||||
} else {
|
||||
setShowBackwardSign(true);
|
||||
}
|
||||
|
||||
const pressAnim = isForward ? forwardPressAnim : backwardPressAnim;
|
||||
const slideAnim = isForward ? forwardSlideAnim : backwardSlideAnim;
|
||||
const scaleAnim = isForward ? forwardScaleAnim : backwardScaleAnim;
|
||||
const arcOpacity = isForward ? forwardArcOpacity : backwardArcOpacity;
|
||||
const arcRotation = isForward ? forwardArcRotation : backwardArcRotation;
|
||||
|
||||
Animated.parallel([
|
||||
// Button press effect (circle flash)
|
||||
Animated.sequence([
|
||||
Animated.timing(pressAnim, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pressAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
// Number slide out
|
||||
Animated.sequence([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: isForward ? (seekButtonSize * 0.75) : -(seekButtonSize * 0.75),
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 120,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
// Button scale pulse
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1.15,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
// Arc sweep animation
|
||||
Animated.parallel([
|
||||
Animated.timing(arcOpacity, {
|
||||
toValue: 1,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(arcRotation, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
]).start(() => {
|
||||
if (isForward) {
|
||||
setShowForwardSign(false);
|
||||
} else {
|
||||
setShowBackwardSign(false);
|
||||
}
|
||||
arcOpacity.setValue(0);
|
||||
arcRotation.setValue(0);
|
||||
});
|
||||
|
||||
skip(seconds);
|
||||
};
|
||||
|
||||
/* Handle Play/Pause with Animation */
|
||||
const handlePlayPauseWithAnimation = () => {
|
||||
Animated.sequence([
|
||||
Animated.timing(playPressAnim, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(playPressAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
Animated.sequence([
|
||||
Animated.timing(playIconScale, {
|
||||
toValue: 0.85,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(playIconScale, {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
togglePlayback();
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, { opacity: fadeAnim, zIndex: 20 }]}
|
||||
|
|
@ -146,35 +296,186 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Center Controls (Play/Pause, Skip) */}
|
||||
<View style={styles.controls}>
|
||||
{/* Left Skip Button */}
|
||||
<TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}>
|
||||
<View style={{ transform: [{ rotate: '-90deg' }] }}>
|
||||
<Ionicons name="reload-outline" size={28} color="white" />
|
||||
</View>
|
||||
<Text style={styles.skipText}>10</Text>
|
||||
|
||||
{/* Center Controls - CloudStream Style */}
|
||||
<View style={[styles.controls, {
|
||||
transform: [{ translateY: -(playButtonSize / 2) }]
|
||||
}]}>
|
||||
|
||||
{/* Backward Seek Button (-10s) */}
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSeekWithAnimation(-10)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Animated.View style={[
|
||||
styles.seekButtonContainer,
|
||||
{
|
||||
width: seekButtonSize,
|
||||
height: seekButtonSize,
|
||||
transform: [{ scale: backwardScaleAnim }]
|
||||
}
|
||||
]}>
|
||||
<Ionicons
|
||||
name="reload-outline"
|
||||
size={seekIconSize}
|
||||
color="white"
|
||||
style={{ transform: [{ scaleX: -1 }] }}
|
||||
/>
|
||||
<Animated.View style={[
|
||||
styles.buttonCircle,
|
||||
{
|
||||
opacity: backwardPressAnim,
|
||||
width: seekButtonSize * 0.6, // 60% of seek button
|
||||
height: seekButtonSize * 0.6,
|
||||
borderRadius: (seekButtonSize * 0.6) / 2,
|
||||
}
|
||||
]} />
|
||||
<View style={[styles.seekNumberContainer, {
|
||||
width: seekButtonSize,
|
||||
height: seekButtonSize,
|
||||
}]}>
|
||||
<Animated.Text style={[
|
||||
styles.seekNumber,
|
||||
{
|
||||
fontSize: seekNumberSize,
|
||||
marginLeft: 7, // Opposite offset for flipped icon
|
||||
transform: [{ translateX: backwardSlideAnim }]
|
||||
}
|
||||
]}>
|
||||
{showBackwardSign ? '-10' : '10'}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[
|
||||
styles.arcContainer,
|
||||
{
|
||||
width: seekButtonSize,
|
||||
height: seekButtonSize,
|
||||
opacity: backwardArcOpacity,
|
||||
transform: [{
|
||||
rotate: backwardArcRotation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['90deg', '-90deg']
|
||||
})
|
||||
}]
|
||||
}
|
||||
]}>
|
||||
<View style={[
|
||||
styles.arcLeft,
|
||||
{
|
||||
width: seekButtonSize,
|
||||
height: seekButtonSize,
|
||||
borderRadius: seekButtonSize / 2,
|
||||
borderWidth: arcBorderWidth,
|
||||
}
|
||||
]} />
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Play/Pause Button */}
|
||||
<TouchableOpacity onPress={togglePlayback} style={styles.playButton}>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={46}
|
||||
color="white"
|
||||
style={{ opacity: 0.9 }}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayPauseWithAnimation}
|
||||
activeOpacity={0.7}
|
||||
style={{ marginHorizontal: buttonSpacing }}
|
||||
>
|
||||
<View style={[styles.playButtonCircle, { width: playButtonSize, height: playButtonSize }]}>
|
||||
<Animated.View style={[
|
||||
styles.playPressCircle,
|
||||
{
|
||||
opacity: playPressAnim,
|
||||
width: playButtonSize * 0.85, // ← 85% of button size
|
||||
height: playButtonSize * 0.85,
|
||||
borderRadius: (playButtonSize * 0.85) / 2, // ← Half of width/height for circle
|
||||
}
|
||||
]} />
|
||||
<Animated.View style={{
|
||||
transform: [{ scale: playIconScale }],
|
||||
opacity: playIconOpacity
|
||||
}}>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={playIconSize}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Right Skip Button */}
|
||||
<TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}>
|
||||
<View style={{ transform: [{ rotate: '90deg' }] }}>
|
||||
<Ionicons name="reload-outline" size={28} color="white" />
|
||||
</View>
|
||||
<Text style={styles.skipText}>10</Text>
|
||||
{/* Forward Seek Button (+10s) */}
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSeekWithAnimation(10)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Animated.View style={[
|
||||
styles.seekButtonContainer,
|
||||
{
|
||||
width: seekButtonSize,
|
||||
height: seekButtonSize,
|
||||
transform: [{ scale: forwardScaleAnim }]
|
||||
}
|
||||
]}>
|
||||
<Ionicons
|
||||
name="reload-outline"
|
||||
size={seekIconSize}
|
||||
color="white"
|
||||
/>
|
||||
<Animated.View style={[
|
||||
styles.buttonCircle,
|
||||
{
|
||||
opacity: forwardPressAnim,
|
||||
width: seekButtonSize * 0.6, // 60% of seek button
|
||||
height: seekButtonSize * 0.6,
|
||||
borderRadius: (seekButtonSize * 0.6) / 2,
|
||||
}
|
||||
]} />
|
||||
<View style={[styles.seekNumberContainer, {
|
||||
width: seekButtonSize,
|
||||
height: seekButtonSize,
|
||||
}]}>
|
||||
<Animated.Text style={[
|
||||
styles.seekNumber,
|
||||
{
|
||||
fontSize: seekNumberSize,
|
||||
transform: [{ translateX: forwardSlideAnim }]
|
||||
}
|
||||
]}>
|
||||
{showForwardSign ? '+10' : '10'}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
<Animated.View style={[
|
||||
styles.arcContainer,
|
||||
{
|
||||
width: seekButtonSize,
|
||||
height: seekButtonSize,
|
||||
},
|
||||
{
|
||||
opacity: forwardArcOpacity,
|
||||
transform: [{
|
||||
rotate: forwardArcRotation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['-90deg', '90deg']
|
||||
})
|
||||
}]
|
||||
}
|
||||
]}>
|
||||
<View style={[
|
||||
styles.arcRight,
|
||||
{
|
||||
width: seekButtonSize,
|
||||
height: seekButtonSize,
|
||||
borderRadius: seekButtonSize / 2,
|
||||
borderWidth: arcBorderWidth,
|
||||
}
|
||||
]} />
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Bottom Gradient */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.7)']}
|
||||
|
|
|
|||
|
|
@ -97,42 +97,102 @@ export const styles = StyleSheet.create({
|
|||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
|
||||
|
||||
/* CloudStream Style - Center Controls */
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '50%',
|
||||
transform: [{ translateY: -30 }],
|
||||
paddingHorizontal: 40,
|
||||
transform: [{ translateY: -50 }],
|
||||
paddingHorizontal: 20,
|
||||
zIndex: 1000,
|
||||
},
|
||||
|
||||
/* CloudStream Style - Seek Buttons */
|
||||
seekButtonContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
buttonCircle: {
|
||||
position: 'absolute',
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
seekNumberContainer: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
seekNumber: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 24,
|
||||
fontWeight: '500',
|
||||
opacity: 1,
|
||||
textAlign: 'center',
|
||||
marginLeft: -7, // Adjusted for better centering with icon
|
||||
},
|
||||
|
||||
/* CloudStream Style - Play Button */
|
||||
playButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 40,
|
||||
padding: 15,
|
||||
width: 80,
|
||||
height: 80,
|
||||
},
|
||||
skipButton: {
|
||||
flexDirection: 'column',
|
||||
playButtonCircle: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
width: 60,
|
||||
position: 'relative',
|
||||
},
|
||||
skipText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 4,
|
||||
playIcon: {
|
||||
color: '#FFFFFF',
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
/* CloudStream Style - Arc Animations */
|
||||
arcContainer: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
arcLeft: {
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderTopColor: 'transparent',
|
||||
borderRightColor: 'transparent',
|
||||
borderBottomColor: 'transparent',
|
||||
position: 'absolute',
|
||||
},
|
||||
arcRight: {
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderTopColor: 'transparent',
|
||||
borderLeftColor: 'transparent',
|
||||
borderBottomColor: 'transparent',
|
||||
position: 'absolute',
|
||||
},
|
||||
playPressCircle: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
bottomControls: {
|
||||
gap: 12,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue