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:
Qarqun 2025-10-20 19:13:46 +08:00
parent 23acda3167
commit e305dee777
2 changed files with 402 additions and 41 deletions

View file

@ -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)']}

View file

@ -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,
},