mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
611 lines
No EOL
20 KiB
TypeScript
611 lines
No EOL
20 KiB
TypeScript
import React from 'react';
|
|
import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform, Dimensions } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import Feather from 'react-native-vector-icons/Feather';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import Slider from '@react-native-community/slider';
|
|
import { styles } from '../utils/playerStyles'; // Updated styles
|
|
import { getTrackDisplayName } from '../utils/playerUtils';
|
|
import { useTheme } from '../../../contexts/ThemeContext';
|
|
|
|
interface PlayerControlsProps {
|
|
showControls: boolean;
|
|
fadeAnim: Animated.Value;
|
|
paused: boolean;
|
|
title: string;
|
|
episodeTitle?: string;
|
|
season?: number;
|
|
episode?: number;
|
|
quality?: string;
|
|
year?: number;
|
|
streamProvider?: string;
|
|
streamName?: string;
|
|
currentTime: number;
|
|
duration: number;
|
|
zoomScale: number;
|
|
currentResizeMode?: string;
|
|
ksAudioTracks: Array<{ id: number, name: string, language?: string }>;
|
|
selectedAudioTrack: number | null;
|
|
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
|
togglePlayback: () => void;
|
|
skip: (seconds: number) => void;
|
|
handleClose: () => void;
|
|
cycleAspectRatio: () => void;
|
|
cyclePlaybackSpeed: () => void;
|
|
currentPlaybackSpeed: number;
|
|
setShowAudioModal: (show: boolean) => void;
|
|
setShowSubtitleModal: (show: boolean) => void;
|
|
setShowSpeedModal: (show: boolean) => void;
|
|
isSubtitleModalOpen?: boolean;
|
|
setShowSourcesModal?: (show: boolean) => void;
|
|
setShowEpisodesModal?: (show: boolean) => void;
|
|
// Slider-specific props
|
|
onSliderValueChange: (value: number) => void;
|
|
onSlidingStart: () => void;
|
|
onSlidingComplete: (value: number) => void;
|
|
buffered: number;
|
|
formatTime: (seconds: number) => string;
|
|
playerBackend?: string;
|
|
// AirPlay props
|
|
isAirPlayActive?: boolean;
|
|
allowsAirPlay?: boolean;
|
|
onAirPlayPress?: () => void;
|
|
// MPV Switch (Android only)
|
|
onSwitchToMPV?: () => void;
|
|
useExoPlayer?: boolean;
|
|
}
|
|
|
|
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
showControls,
|
|
fadeAnim,
|
|
paused,
|
|
title,
|
|
episodeTitle,
|
|
season,
|
|
episode,
|
|
quality,
|
|
year,
|
|
streamProvider,
|
|
streamName,
|
|
currentTime,
|
|
duration,
|
|
zoomScale,
|
|
currentResizeMode,
|
|
ksAudioTracks,
|
|
selectedAudioTrack,
|
|
availableStreams,
|
|
togglePlayback,
|
|
skip,
|
|
handleClose,
|
|
cycleAspectRatio,
|
|
cyclePlaybackSpeed,
|
|
currentPlaybackSpeed,
|
|
setShowAudioModal,
|
|
setShowSubtitleModal,
|
|
setShowSpeedModal,
|
|
isSubtitleModalOpen,
|
|
setShowSourcesModal,
|
|
setShowEpisodesModal,
|
|
onSliderValueChange,
|
|
onSlidingStart,
|
|
onSlidingComplete,
|
|
buffered,
|
|
formatTime,
|
|
playerBackend,
|
|
isAirPlayActive,
|
|
allowsAirPlay,
|
|
onAirPlayPress,
|
|
onSwitchToMPV,
|
|
useExoPlayer,
|
|
}) => {
|
|
const { currentTheme } = useTheme();
|
|
|
|
|
|
/* Responsive Spacing */
|
|
const screenWidth = Dimensions.get('window').width;
|
|
const buttonSpacing = screenWidth * 0.10; // Reduced from 15% to 10%
|
|
|
|
const playButtonSize = screenWidth * 0.08; // 8% of screen width (reduced from 12%)
|
|
const playIconSizeCalculated = playButtonSize * 0.6; // 60% of button size
|
|
const seekButtonSize = screenWidth * 0.07; // 7% of screen width (reduced from 11%)
|
|
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) => {
|
|
if (w >= BREAKPOINTS.tv) return 'tv';
|
|
if (w >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
|
if (w >= BREAKPOINTS.tablet) return 'tablet';
|
|
return 'phone';
|
|
};
|
|
const deviceType = getDeviceType(deviceWidth);
|
|
const isTablet = deviceType === 'tablet';
|
|
const isLargeTablet = deviceType === 'largeTablet';
|
|
const isTV = deviceType === 'tv';
|
|
|
|
const closeIconSize = isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20;
|
|
const skipIconSize = isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20;
|
|
const playIconSize = isTV ? 48 : isLargeTablet ? 40 : isTablet ? 36 : 32;
|
|
return (
|
|
<Animated.View
|
|
style={[StyleSheet.absoluteFill, { opacity: fadeAnim, zIndex: 20 }]}
|
|
pointerEvents={showControls ? 'box-none' : 'none'}
|
|
>
|
|
{/* Progress slider with native iOS slider */}
|
|
<View style={styles.sliderContainer}>
|
|
<Slider
|
|
style={{
|
|
width: '100%',
|
|
height: 40,
|
|
marginHorizontal: 0,
|
|
}}
|
|
minimumValue={0}
|
|
maximumValue={duration || 1}
|
|
value={currentTime}
|
|
onValueChange={onSliderValueChange}
|
|
onSlidingStart={onSlidingStart}
|
|
onSlidingComplete={onSlidingComplete}
|
|
minimumTrackTintColor={currentTheme.colors.primary}
|
|
maximumTrackTintColor={currentTheme.colors.mediumEmphasis}
|
|
thumbTintColor={Platform.OS === 'android' ? currentTheme.colors.white : undefined}
|
|
tapToSeek={Platform.OS === 'ios'}
|
|
/>
|
|
<View style={[styles.timeDisplay, { paddingHorizontal: 14 }]}>
|
|
<View style={styles.timeContainer}>
|
|
<Text style={styles.duration}>{formatTime(currentTime)}</Text>
|
|
</View>
|
|
<View style={styles.timeContainer}>
|
|
<Text style={styles.duration}>{formatTime(duration)}</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Controls Overlay */}
|
|
<View style={styles.controlsContainer}>
|
|
{/* Top Gradient & Header */}
|
|
<LinearGradient
|
|
colors={['rgba(0,0,0,0.7)', 'transparent']}
|
|
style={styles.topGradient}
|
|
>
|
|
<View style={styles.header}>
|
|
{/* Title Section - Enhanced with metadata */}
|
|
<View style={styles.titleSection}>
|
|
<Text style={styles.title}>{title}</Text>
|
|
{/* Show season and episode for series */}
|
|
{season && episode && (
|
|
<Text style={styles.episodeInfo}>
|
|
S{season}E{episode} {episodeTitle && `• ${episodeTitle}`}
|
|
</Text>
|
|
)}
|
|
{/* Show year and provider (quality chip removed) */}
|
|
<View style={styles.metadataRow}>
|
|
{year && <Text style={styles.metadataText}>{year}</Text>}
|
|
{streamName && <Text style={styles.providerText}>via {streamName}</Text>}
|
|
</View>
|
|
{playerBackend && (
|
|
<View style={styles.metadataRow}>
|
|
<Text style={[styles.providerText, { fontSize: 11, opacity: 0.9 }]}>{playerBackend}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
{/* AirPlay Button - iOS only, KSAVPlayer only */}
|
|
{Platform.OS === 'ios' && onAirPlayPress && playerBackend === 'KSAVPlayer' && (
|
|
<TouchableOpacity
|
|
style={{ padding: 8 }}
|
|
onPress={onAirPlayPress}
|
|
>
|
|
<Feather
|
|
name="airplay"
|
|
size={closeIconSize}
|
|
color={isAirPlayActive ? currentTheme.colors.primary : "white"}
|
|
/>
|
|
</TouchableOpacity>
|
|
)}
|
|
{/* Switch to MPV Button - Android only, when using ExoPlayer */}
|
|
{Platform.OS === 'android' && onSwitchToMPV && useExoPlayer && (
|
|
<TouchableOpacity
|
|
style={{ padding: 8 }}
|
|
onPress={onSwitchToMPV}
|
|
>
|
|
<Ionicons
|
|
name="swap-horizontal"
|
|
size={closeIconSize}
|
|
color="white"
|
|
/>
|
|
</TouchableOpacity>
|
|
)}
|
|
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
|
|
<Ionicons name="close" size={closeIconSize} color="white" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</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,
|
|
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,
|
|
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,
|
|
height: playButtonSize * 0.85,
|
|
borderRadius: (playButtonSize * 0.85) / 2,
|
|
}
|
|
]} />
|
|
<Animated.View style={{
|
|
transform: [{ scale: playIconScale }],
|
|
opacity: playIconOpacity
|
|
}}>
|
|
<Ionicons
|
|
name={paused ? "play" : "pause"}
|
|
size={playIconSizeCalculated}
|
|
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,
|
|
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)']}
|
|
style={styles.bottomGradient}
|
|
pointerEvents="box-none"
|
|
>
|
|
<View style={styles.bottomControls} pointerEvents="box-none">
|
|
{/* Center Buttons Container with rounded background - wraps all buttons */}
|
|
<View style={styles.centerControlsContainer} pointerEvents="box-none">
|
|
{/* Left Side: Aspect Ratio Button */}
|
|
<TouchableOpacity style={styles.iconButton} onPress={cycleAspectRatio}>
|
|
<Ionicons name="expand-outline" size={24} color="white" />
|
|
</TouchableOpacity>
|
|
|
|
{/* Subtitle Button */}
|
|
<TouchableOpacity
|
|
style={styles.iconButton}
|
|
onPress={() => setShowSubtitleModal(!isSubtitleModalOpen)}
|
|
>
|
|
<Ionicons name="text" size={24} color="white" />
|
|
</TouchableOpacity>
|
|
|
|
{/* Change Source Button */}
|
|
{setShowSourcesModal && (
|
|
<TouchableOpacity
|
|
style={styles.iconButton}
|
|
onPress={() => setShowSourcesModal(true)}
|
|
>
|
|
<Ionicons name="cloud-outline" size={24} color="white" />
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{/* Playback Speed Button */}
|
|
<TouchableOpacity style={styles.iconButton} onPress={() => setShowSpeedModal(true)}>
|
|
<Ionicons name="speedometer-outline" size={24} color="white" />
|
|
</TouchableOpacity>
|
|
|
|
{/* Audio Button */}
|
|
<TouchableOpacity
|
|
style={styles.iconButton}
|
|
onPress={() => setShowAudioModal(true)}
|
|
disabled={ksAudioTracks.length <= 1}
|
|
>
|
|
<Ionicons
|
|
name="musical-notes-outline"
|
|
size={24}
|
|
color={ksAudioTracks.length <= 1 ? 'grey' : 'white'}
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
{/* Right Side: Episodes Button */}
|
|
{setShowEpisodesModal && (
|
|
<TouchableOpacity
|
|
style={styles.iconButton}
|
|
onPress={() => setShowEpisodesModal(true)}
|
|
>
|
|
<Ionicons name="list" size={24} color="white" />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</LinearGradient>
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
export default PlayerControls; |