Merge pull request #202 from qarqun/main

Responsive Video Player Controls with animations
This commit is contained in:
tapframe 2025-10-21 13:58:23 +05:30 committed by GitHub
commit b2cfc19e96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 413 additions and 6 deletions

View file

@ -1,2 +1,3 @@
{
"java.compile.nullAnalysis.mode": "automatic"
}

View file

@ -726,7 +726,7 @@ public class ReactExoplayerView extends FrameLayout implements
DefaultRenderersFactory renderersFactory =
new DefaultRenderersFactory(getContext())
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
.setEnableDecoderFallback(true)
.forceEnableMediaCodecAsynchronousQueueing();

View file

@ -90,6 +90,156 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
onAirPlayPress,
}) => {
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();
};
const deviceWidth = Dimensions.get('window').width;
const BREAKPOINTS = { phone: 0, tablet: 768, largeTablet: 1024, tv: 1440 } as const;
const getDeviceType = (w: number) => {
@ -170,6 +320,179 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</View>
</LinearGradient>
{/* 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={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>
{/* 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>
{/* Center Controls (Play/Pause, Skip) */}
<View style={styles.controls}>
<TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}>
@ -185,6 +508,10 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</TouchableOpacity>
</View>
{/* Bottom Gradient */}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.7)']}

View file

@ -131,11 +131,19 @@ export const styles = StyleSheet.create({
closeButton: {
padding: 8,
},
/* CloudStream Style - Center Controls */
controls: {
position: 'absolute',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
left: 0,
right: 0,
top: '50%',
transform: [{ translateY: -50 }],
paddingHorizontal: 20,
gap: controlsGap,
left: 0,
right: 0,
@ -143,20 +151,91 @@ export const styles = StyleSheet.create({
transform: [{ translateY: controlsTranslateY }],
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: {
justifyContent: 'center',
alignItems: 'center',
padding: 10,
},
skipButton: {
alignItems: 'center',
justifyContent: 'center',
},
playButtonCircle: {
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
},
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',
skipText: {
color: 'white',
fontSize: skipTextFont,
marginTop: 2,
},
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,
},