Compare commits

...

7 commits
main ... pr-202

Author SHA1 Message Date
tapframe
3164016752 resolve merge conflicts 2025-10-21 13:38:03 +05:30
Qarqun
e305dee777 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
2025-10-20 19:13:46 +08:00
qarqun
23acda3167
Merge branch 'tapframe:main' into main 2025-10-20 15:35:59 +08:00
qarqun
6e975ffe26
Merge branch 'tapframe:main' into main 2025-10-19 15:42:24 +08:00
qarqun
ca2e95e6f4
Merge branch 'tapframe:main' into main 2025-10-19 12:04:34 +08:00
Qarqun
f895428e3d Merge branch 'main' of https://github.com/qarqun/NuvioStreaming 2025-10-19 01:21:48 +08:00
Qarqun
698456c205 refactor(player): enhance video controls with modern streaming style
- Moved inline styles to playerStyles.ts for better maintainability
- Redesigned player controls for better user experience:
  - Enhanced skip buttons with rotate animations and semi-transparent backgrounds
  - Enlarged center play/pause button with improved visibility
  - Optimized touch targets and spacing for better interaction
  - Standardized button dimensions and layout

Files changed:
- src/components/player/controls/PlayerControls.tsx
- src/components/player/utils/playerStyles.ts
2025-10-19 01:11:08 +08:00
4 changed files with 410 additions and 21 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,7 +90,9 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
onAirPlayPress,
}) => {
const { currentTheme } = useTheme();
const deviceWidth = Dimensions.get('window').width;
/* Responsive Spacing - Merged with tablet support */
const screenWidth = Dimensions.get('window').width;
const BREAKPOINTS = { phone: 0, tablet: 768, largeTablet: 1024, tv: 1440 } as const;
const getDeviceType = (w: number) => {
if (w >= BREAKPOINTS.tv) return 'tv';
@ -98,14 +100,161 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
if (w >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
const deviceType = getDeviceType(deviceWidth);
const deviceType = getDeviceType(screenWidth);
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
// Responsive button sizing - combines percentage-based with breakpoint scaling
const baseButtonSpacing = screenWidth * 0.15;
const buttonSpacing = isTV ? baseButtonSpacing * 1.2 : isLargeTablet ? baseButtonSpacing * 1.1 : isTablet ? baseButtonSpacing : baseButtonSpacing * 0.9;
const basePlayButtonSize = screenWidth * 0.12;
const playButtonSize = isTV ? basePlayButtonSize * 1.2 : isLargeTablet ? basePlayButtonSize * 1.1 : isTablet ? basePlayButtonSize : basePlayButtonSize * 0.9;
const playIconSize = playButtonSize * 0.6;
const baseSeekButtonSize = screenWidth * 0.11;
const seekButtonSize = isTV ? baseSeekButtonSize * 1.2 : isLargeTablet ? baseSeekButtonSize * 1.1 : isTablet ? baseSeekButtonSize : baseSeekButtonSize * 0.9;
const seekIconSize = seekButtonSize * 0.75;
const seekNumberSize = seekButtonSize * 0.25;
const arcBorderWidth = seekButtonSize * 0.05;
// Icon sizes for other controls
const closeIconSize = isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24;
const skipIconSize = isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24;
const playIconSize = isTV ? 56 : isLargeTablet ? 48 : isTablet ? 44 : 40;
/* 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 }]}
@ -170,21 +319,185 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</View>
</LinearGradient>
{/* Center Controls (Play/Pause, Skip) */}
<View style={styles.controls}>
<TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}>
<Ionicons name="play-back" size={skipIconSize} color="white" />
<Text style={styles.skipText}>10</Text>
{/* Center Controls - CloudStream Style with Responsive Sizing */}
<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>
<TouchableOpacity onPress={togglePlayback} style={styles.playButton}>
<Ionicons name={paused ? "play" : "pause"} size={playIconSize} color="white" />
{/* 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>
<TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}>
<Ionicons name="play-forward" size={skipIconSize} color="white" />
<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

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