mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
refactor ksplayer
This commit is contained in:
parent
a50f8de913
commit
0d6d69e0a8
38 changed files with 3396 additions and 3332 deletions
|
|
@ -4,26 +4,29 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
|
||||
// Hooks
|
||||
import { usePlayerState } from './android/hooks/usePlayerState';
|
||||
// Shared Hooks (cross-platform)
|
||||
import {
|
||||
usePlayerState,
|
||||
usePlayerModals,
|
||||
useSpeedControl,
|
||||
useOpeningAnimation
|
||||
} from './hooks';
|
||||
|
||||
// Android-specific hooks (VLC integration, dual player support)
|
||||
import { usePlayerSetup } from './android/hooks/usePlayerSetup';
|
||||
import { useVlcPlayer } from './android/hooks/useVlcPlayer';
|
||||
import { usePlayerTracks } from './android/hooks/usePlayerTracks';
|
||||
import { useWatchProgress } from './android/hooks/useWatchProgress';
|
||||
import { usePlayerControls } from './android/hooks/usePlayerControls';
|
||||
import { useSpeedControl } from './android/hooks/useSpeedControl';
|
||||
import { useNextEpisode } from './android/hooks/useNextEpisode';
|
||||
import { useOpeningAnimation } from './android/hooks/useOpeningAnimation';
|
||||
import { usePlayerModals } from './android/hooks/usePlayerModals';
|
||||
|
||||
// App-level Hooks
|
||||
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
|
||||
import { useMetadata } from '../../hooks/useMetadata';
|
||||
import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls';
|
||||
|
||||
// Components
|
||||
import { VideoSurface } from './android/components/VideoSurface';
|
||||
import { GestureControls } from './android/components/GestureControls';
|
||||
import { PauseOverlay } from './android/components/PauseOverlay';
|
||||
import { SpeedActivatedOverlay } from './android/components/SpeedActivatedOverlay';
|
||||
// Shared Components
|
||||
import { GestureControls, PauseOverlay, SpeedActivatedOverlay } from './components';
|
||||
import LoadingOverlay from './modals/LoadingOverlay';
|
||||
import PlayerControls from './controls/PlayerControls';
|
||||
import { AudioTrackModal } from './modals/AudioTrackModal';
|
||||
|
|
@ -33,12 +36,14 @@ import { SourcesModal } from './modals/SourcesModal';
|
|||
import { EpisodesModal } from './modals/EpisodesModal';
|
||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||
|
||||
// Android-specific components
|
||||
import { VideoSurface } from './android/components/VideoSurface';
|
||||
|
||||
// Utils
|
||||
import { logger } from '../../utils/logger';
|
||||
import { styles } from './utils/playerStyles';
|
||||
import { formatTime, isHlsStream, processUrlForVLC, getHlsHeaders, defaultAndroidHeaders } from './utils/playerUtils';
|
||||
import { storageService } from '../../services/storageService';
|
||||
// SelectedTrackType removed - using string literals instead
|
||||
|
||||
const DEBUG_MODE = false;
|
||||
|
||||
|
|
@ -543,6 +548,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
onClose={() => modals.setShowEpisodeStreamsModal(false)}
|
||||
episode={modals.selectedEpisodeForStreams}
|
||||
onSelectStream={handleEpisodeStreamSelect}
|
||||
metadata={{ id: id, name: title }}
|
||||
/>
|
||||
|
||||
</View>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,11 +11,23 @@ const getVideoResizeMode = (resizeMode: ResizeModeType) => {
|
|||
switch (resizeMode) {
|
||||
case 'contain': return 'contain';
|
||||
case 'cover': return 'cover';
|
||||
case 'stretch': return 'contain';
|
||||
case 'none': return 'contain';
|
||||
default: return 'contain';
|
||||
}
|
||||
};
|
||||
|
||||
// VLC only supports 'contain' | 'cover' | 'none'
|
||||
const getVlcResizeMode = (resizeMode: ResizeModeType): 'contain' | 'cover' | 'none' => {
|
||||
switch (resizeMode) {
|
||||
case 'contain': return 'contain';
|
||||
case 'cover': return 'cover';
|
||||
case 'stretch': return 'cover'; // stretch is not supported, use cover
|
||||
case 'none': return 'none';
|
||||
default: return 'contain';
|
||||
}
|
||||
};
|
||||
|
||||
interface VideoSurfaceProps {
|
||||
useVLC: boolean;
|
||||
forceVlcRemount: boolean;
|
||||
|
|
@ -137,7 +149,7 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
volume={volume}
|
||||
playbackSpeed={playbackSpeed}
|
||||
zoomScale={zoomScale}
|
||||
resizeMode={resizeMode}
|
||||
resizeMode={getVlcResizeMode(resizeMode)}
|
||||
onLoad={(data) => {
|
||||
vlcLoadedRef.current = true;
|
||||
onLoad(data);
|
||||
|
|
|
|||
199
src/components/player/components/GestureControls.tsx
Normal file
199
src/components/player/components/GestureControls.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
TapGestureHandler,
|
||||
PanGestureHandler,
|
||||
LongPressGestureHandler,
|
||||
State
|
||||
} from 'react-native-gesture-handler';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { styles as localStyles } from '../utils/playerStyles';
|
||||
|
||||
interface GestureControlsProps {
|
||||
screenDimensions: { width: number, height: number };
|
||||
gestureControls: any;
|
||||
onLongPressActivated: () => void;
|
||||
onLongPressEnd: () => void;
|
||||
onLongPressStateChange: (event: any) => void;
|
||||
toggleControls: () => void;
|
||||
showControls: boolean;
|
||||
hideControls: () => void;
|
||||
volume: number;
|
||||
brightness: number;
|
||||
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
}
|
||||
|
||||
export const GestureControls: React.FC<GestureControlsProps> = ({
|
||||
screenDimensions,
|
||||
gestureControls,
|
||||
onLongPressActivated,
|
||||
onLongPressEnd,
|
||||
onLongPressStateChange,
|
||||
toggleControls,
|
||||
showControls,
|
||||
hideControls,
|
||||
volume,
|
||||
brightness,
|
||||
controlsTimeout
|
||||
}) => {
|
||||
|
||||
const getVolumeIcon = (value: number) => {
|
||||
if (value === 0) return 'volume-off';
|
||||
if (value < 0.3) return 'volume-mute';
|
||||
if (value < 0.6) return 'volume-down';
|
||||
return 'volume-up';
|
||||
};
|
||||
|
||||
const getBrightnessIcon = (value: number) => {
|
||||
if (value < 0.3) return 'brightness-low';
|
||||
if (value < 0.7) return 'brightness-medium';
|
||||
return 'brightness-high';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left side gesture handler - brightness + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Right side gesture handler - volume + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 5,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Volume/Brightness Pill Overlay - Compact top design */}
|
||||
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={[
|
||||
localStyles.gestureIndicatorPill,
|
||||
gestureControls.showVolumeOverlay && volume === 0 && {
|
||||
backgroundColor: 'rgba(96, 20, 16, 0.85)'
|
||||
}
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
localStyles.iconWrapper,
|
||||
gestureControls.showVolumeOverlay && volume === 0 && {
|
||||
backgroundColor: 'rgba(242, 184, 181, 0.3)'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={
|
||||
gestureControls.showVolumeOverlay
|
||||
? getVolumeIcon(volume)
|
||||
: getBrightnessIcon(brightness)
|
||||
}
|
||||
size={18}
|
||||
color={
|
||||
gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(242, 184, 181, 1)'
|
||||
: 'rgba(255, 255, 255, 0.9)'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
localStyles.gestureText,
|
||||
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181, 1)' }
|
||||
]}
|
||||
>
|
||||
{gestureControls.showVolumeOverlay && volume === 0
|
||||
? "Muted"
|
||||
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%`
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
259
src/components/player/components/PauseOverlay.tsx
Normal file
259
src/components/player/components/PauseOverlay.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView, Animated, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// Delay before showing pause overlay (in milliseconds)
|
||||
const PAUSE_OVERLAY_DELAY = 5000;
|
||||
|
||||
interface PauseOverlayProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
episodeTitle?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
year?: string | number;
|
||||
type: string;
|
||||
description: string;
|
||||
cast: any[];
|
||||
screenDimensions: { width: number, height: number };
|
||||
}
|
||||
|
||||
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
title,
|
||||
episodeTitle,
|
||||
season,
|
||||
episode,
|
||||
year,
|
||||
type,
|
||||
description,
|
||||
cast,
|
||||
screenDimensions
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Internal state to track if overlay should actually be shown (after delay)
|
||||
const [shouldShow, setShouldShow] = useState(false);
|
||||
const delayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Handle delay logic - show overlay only after paused for 5 seconds
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Start timer to show overlay after delay
|
||||
delayTimerRef.current = setTimeout(() => {
|
||||
setShouldShow(true);
|
||||
}, PAUSE_OVERLAY_DELAY);
|
||||
} else {
|
||||
// Immediately hide when not paused
|
||||
if (delayTimerRef.current) {
|
||||
clearTimeout(delayTimerRef.current);
|
||||
delayTimerRef.current = null;
|
||||
}
|
||||
setShouldShow(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (delayTimerRef.current) {
|
||||
clearTimeout(delayTimerRef.current);
|
||||
delayTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
// Internal Animation State
|
||||
const pauseOverlayOpacity = useRef(new Animated.Value(shouldShow ? 1 : 0)).current;
|
||||
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
|
||||
const metadataOpacity = useRef(new Animated.Value(1)).current;
|
||||
const metadataScale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Cast Details State
|
||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
||||
const [showCastDetails, setShowCastDetails] = useState(false);
|
||||
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
|
||||
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(pauseOverlayOpacity, {
|
||||
toValue: shouldShow ? 1 : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true
|
||||
}).start();
|
||||
}, [shouldShow]);
|
||||
|
||||
if (!shouldShow && !showCastDetails) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
opacity: pauseOverlayOpacity,
|
||||
}}
|
||||
>
|
||||
{/* Horizontal Fade */}
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
|
||||
locations={[0, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</View>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.6)',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.2)',
|
||||
'rgba(0,0,0,0.0)'
|
||||
]}
|
||||
locations={[0, 0.3, 0.6, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<Animated.View style={{
|
||||
position: 'absolute',
|
||||
left: 24 + insets.left,
|
||||
right: 24 + insets.right,
|
||||
top: 24 + insets.top,
|
||||
bottom: 110 + insets.bottom,
|
||||
transform: [{ translateY: pauseOverlayTranslateY }]
|
||||
}}>
|
||||
{showCastDetails && selectedCastMember ? (
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
opacity: castDetailsOpacity,
|
||||
transform: [{ scale: castDetailsScale }]
|
||||
}}
|
||||
>
|
||||
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
|
||||
<TouchableOpacity
|
||||
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
|
||||
onPress={() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
|
||||
]).start(() => {
|
||||
setShowCastDetails(false);
|
||||
setSelectedCastMember(null);
|
||||
Animated.parallel([
|
||||
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
|
||||
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
|
||||
]).start();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
|
||||
{selectedCastMember.profile_path && (
|
||||
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
|
||||
<FastImage
|
||||
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
|
||||
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flex: 1, paddingTop: 8 }}>
|
||||
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
|
||||
{selectedCastMember.name}
|
||||
</Text>
|
||||
{selectedCastMember.character && (
|
||||
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
|
||||
as {selectedCastMember.character}
|
||||
</Text>
|
||||
)}
|
||||
{selectedCastMember.biography && (
|
||||
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
|
||||
{selectedCastMember.biography}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
|
||||
<View>
|
||||
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
|
||||
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
{!!year && (
|
||||
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
|
||||
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
|
||||
</Text>
|
||||
)}
|
||||
{!!episodeTitle && (
|
||||
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
|
||||
{episodeTitle}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{cast && cast.length > 0 && (
|
||||
<View style={{ marginTop: 16 }}>
|
||||
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||
{cast.slice(0, 6).map((castMember: any, index: number) => (
|
||||
<TouchableOpacity
|
||||
key={castMember.id || index}
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
|
||||
onPress={() => {
|
||||
setSelectedCastMember(castMember);
|
||||
Animated.parallel([
|
||||
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
|
||||
]).start(() => {
|
||||
setShowCastDetails(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
|
||||
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
|
||||
]).start();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
|
||||
{castMember.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
38
src/components/player/components/SpeedActivatedOverlay.tsx
Normal file
38
src/components/player/components/SpeedActivatedOverlay.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Shared Speed Activated Overlay Component
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, Animated } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { styles } from '../utils/playerStyles';
|
||||
|
||||
interface SpeedActivatedOverlayProps {
|
||||
visible: boolean;
|
||||
opacity: Animated.Value;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
|
||||
visible,
|
||||
opacity,
|
||||
speed
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.speedActivatedOverlay,
|
||||
{ opacity: opacity }
|
||||
]}
|
||||
>
|
||||
<View style={styles.speedActivatedContainer}>
|
||||
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
|
||||
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeedActivatedOverlay;
|
||||
8
src/components/player/components/index.ts
Normal file
8
src/components/player/components/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Shared Player Components
|
||||
* Export all reusable components for both Android and iOS players
|
||||
*/
|
||||
|
||||
export { SpeedActivatedOverlay } from './SpeedActivatedOverlay';
|
||||
export { PauseOverlay } from './PauseOverlay';
|
||||
export { GestureControls } from './GestureControls';
|
||||
21
src/components/player/hooks/index.ts
Normal file
21
src/components/player/hooks/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Shared Player Hooks
|
||||
* Export all reusable hooks for both Android and iOS players
|
||||
*/
|
||||
|
||||
// State Management
|
||||
export { usePlayerState, type PlayerResizeMode } from './usePlayerState';
|
||||
export { usePlayerModals } from './usePlayerModals';
|
||||
export { usePlayerTracks } from './usePlayerTracks';
|
||||
export { useCustomSubtitles } from './useCustomSubtitles';
|
||||
|
||||
// Controls & Playback
|
||||
export { usePlayerControls } from './usePlayerControls';
|
||||
export { useSpeedControl } from './useSpeedControl';
|
||||
|
||||
// Animation & UI
|
||||
export { useOpeningAnimation } from './useOpeningAnimation';
|
||||
export { usePlayerSetup } from './usePlayerSetup';
|
||||
|
||||
// Content
|
||||
export { useNextEpisode } from './useNextEpisode';
|
||||
62
src/components/player/hooks/useCustomSubtitles.ts
Normal file
62
src/components/player/hooks/useCustomSubtitles.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Shared Custom Subtitles Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DEFAULT_SUBTITLE_SIZE,
|
||||
SubtitleCue,
|
||||
SubtitleSegment,
|
||||
WyzieSubtitle
|
||||
} from '../utils/playerTypes';
|
||||
|
||||
export const useCustomSubtitles = () => {
|
||||
// Data State
|
||||
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
||||
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
||||
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
|
||||
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
|
||||
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
|
||||
|
||||
// Loading State
|
||||
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
|
||||
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
|
||||
|
||||
// Styling State
|
||||
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
|
||||
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
|
||||
const [subtitleTextColor, setSubtitleTextColor] = useState<string>('#FFFFFF');
|
||||
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState<number>(0.7);
|
||||
const [subtitleTextShadow, setSubtitleTextShadow] = useState<boolean>(true);
|
||||
const [subtitleOutline, setSubtitleOutline] = useState<boolean>(true);
|
||||
const [subtitleOutlineColor, setSubtitleOutlineColor] = useState<string>('#000000');
|
||||
const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState<number>(4);
|
||||
const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center');
|
||||
const [subtitleBottomOffset, setSubtitleBottomOffset] = useState<number>(10);
|
||||
const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState<number>(0);
|
||||
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState<number>(1.2);
|
||||
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState<number>(0);
|
||||
|
||||
return {
|
||||
customSubtitles, setCustomSubtitles,
|
||||
currentSubtitle, setCurrentSubtitle,
|
||||
currentFormattedSegments, setCurrentFormattedSegments,
|
||||
availableSubtitles, setAvailableSubtitles,
|
||||
useCustomSubtitles, setUseCustomSubtitles,
|
||||
isLoadingSubtitles, setIsLoadingSubtitles,
|
||||
isLoadingSubtitleList, setIsLoadingSubtitleList,
|
||||
subtitleSize, setSubtitleSize,
|
||||
subtitleBackground, setSubtitleBackground,
|
||||
subtitleTextColor, setSubtitleTextColor,
|
||||
subtitleBgOpacity, setSubtitleBgOpacity,
|
||||
subtitleTextShadow, setSubtitleTextShadow,
|
||||
subtitleOutline, setSubtitleOutline,
|
||||
subtitleOutlineColor, setSubtitleOutlineColor,
|
||||
subtitleOutlineWidth, setSubtitleOutlineWidth,
|
||||
subtitleAlign, setSubtitleAlign,
|
||||
subtitleBottomOffset, setSubtitleBottomOffset,
|
||||
subtitleLetterSpacing, setSubtitleLetterSpacing,
|
||||
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier,
|
||||
subtitleOffsetSec, setSubtitleOffsetSec
|
||||
};
|
||||
};
|
||||
65
src/components/player/hooks/useNextEpisode.ts
Normal file
65
src/components/player/hooks/useNextEpisode.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Shared Next Episode Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface NextEpisodeConfig {
|
||||
type: string | undefined;
|
||||
season: number | undefined;
|
||||
episode: number | undefined;
|
||||
groupedEpisodes: Record<string, any[]> | undefined;
|
||||
episodeId?: string;
|
||||
}
|
||||
|
||||
export const useNextEpisode = (config: NextEpisodeConfig) => {
|
||||
const { type, season, episode, groupedEpisodes, episodeId } = config;
|
||||
|
||||
// Current description
|
||||
const currentEpisodeDescription = useMemo(() => {
|
||||
try {
|
||||
if (type !== 'series') return '';
|
||||
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
|
||||
if (!allEpisodes || allEpisodes.length === 0) return '';
|
||||
|
||||
let match: any | null = null;
|
||||
if (episodeId) {
|
||||
match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId));
|
||||
}
|
||||
if (!match && season && episode) {
|
||||
match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode);
|
||||
}
|
||||
return (match?.overview || '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [type, groupedEpisodes, episodeId, season, episode]);
|
||||
|
||||
// Next Episode
|
||||
const nextEpisode = useMemo(() => {
|
||||
try {
|
||||
if (type !== 'series' || !season || !episode) return null;
|
||||
const sourceGroups = groupedEpisodes || {};
|
||||
|
||||
const allEpisodes = Object.values(sourceGroups).flat() as any[];
|
||||
if (!allEpisodes || allEpisodes.length === 0) return null;
|
||||
|
||||
// Try to find next episode in same season
|
||||
let nextEp = allEpisodes.find((ep: any) =>
|
||||
ep.season_number === season && ep.episode_number === episode + 1
|
||||
);
|
||||
|
||||
// If not found, try first episode of next season
|
||||
if (!nextEp) {
|
||||
nextEp = allEpisodes.find((ep: any) =>
|
||||
ep.season_number === season + 1 && ep.episode_number === 1
|
||||
);
|
||||
}
|
||||
return nextEp;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [type, season, episode, groupedEpisodes]);
|
||||
|
||||
return { currentEpisodeDescription, nextEpisode };
|
||||
};
|
||||
152
src/components/player/hooks/useOpeningAnimation.ts
Normal file
152
src/components/player/hooks/useOpeningAnimation.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Shared Opening Animation Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Animated, InteractionManager } from 'react-native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => {
|
||||
// Animation Values
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
const openingFadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const openingScaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
const backgroundFadeAnim = useRef(new Animated.Value(1)).current;
|
||||
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
|
||||
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
|
||||
const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false);
|
||||
const [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
|
||||
|
||||
// Prefetch Background
|
||||
useEffect(() => {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
if (backdrop && typeof backdrop === 'string') {
|
||||
setIsBackdropLoaded(false);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
try {
|
||||
FastImage.preload([{ uri: backdrop }]);
|
||||
setIsBackdropLoaded(true);
|
||||
Animated.timing(backdropImageOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} catch (error) {
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(1);
|
||||
}
|
||||
} else {
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
}
|
||||
});
|
||||
return () => task.cancel();
|
||||
}, [backdrop]);
|
||||
|
||||
// Prefetch Logo
|
||||
useEffect(() => {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
const logoUrl = metadata?.logo;
|
||||
if (logoUrl && typeof logoUrl === 'string') {
|
||||
try {
|
||||
FastImage.preload([{ uri: logoUrl }]);
|
||||
} catch (error) { }
|
||||
}
|
||||
});
|
||||
return () => task.cancel();
|
||||
}, [metadata]);
|
||||
|
||||
const startOpeningAnimation = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(logoOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(logoScaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
const createPulseAnimation = () => {
|
||||
return Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const loopPulse = () => {
|
||||
createPulseAnimation().start(() => {
|
||||
if (!isOpeningAnimationComplete) {
|
||||
loopPulse();
|
||||
}
|
||||
});
|
||||
};
|
||||
loopPulse();
|
||||
};
|
||||
|
||||
const completeOpeningAnimation = () => {
|
||||
pulseAnim.stopAnimation();
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(openingFadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(openingScaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 350,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backgroundFadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setIsOpeningAnimationComplete(true);
|
||||
setTimeout(() => {
|
||||
setShouldHideOpeningOverlay(true);
|
||||
}, 450);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isOpeningAnimationComplete) {
|
||||
setIsOpeningAnimationComplete(true);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return {
|
||||
fadeAnim,
|
||||
openingFadeAnim,
|
||||
openingScaleAnim,
|
||||
backgroundFadeAnim,
|
||||
backdropImageOpacityAnim,
|
||||
logoScaleAnim,
|
||||
logoOpacityAnim,
|
||||
pulseAnim,
|
||||
isOpeningAnimationComplete,
|
||||
shouldHideOpeningOverlay,
|
||||
isBackdropLoaded,
|
||||
startOpeningAnimation,
|
||||
completeOpeningAnimation
|
||||
};
|
||||
};
|
||||
81
src/components/player/hooks/usePlayerControls.ts
Normal file
81
src/components/player/hooks/usePlayerControls.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Shared Player Controls Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import { useRef, useCallback, MutableRefObject } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const DEBUG_MODE = false;
|
||||
const END_EPSILON = 0.3;
|
||||
|
||||
interface PlayerControlsConfig {
|
||||
playerRef: MutableRefObject<any>;
|
||||
paused: boolean;
|
||||
setPaused: (paused: boolean) => void;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isSeeking: MutableRefObject<boolean>;
|
||||
isMounted: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
export const usePlayerControls = (config: PlayerControlsConfig) => {
|
||||
const {
|
||||
playerRef,
|
||||
paused,
|
||||
setPaused,
|
||||
currentTime,
|
||||
duration,
|
||||
isSeeking,
|
||||
isMounted
|
||||
} = config;
|
||||
|
||||
// iOS seeking helpers
|
||||
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
|
||||
|
||||
const togglePlayback = useCallback(() => {
|
||||
setPaused(!paused);
|
||||
}, [paused, setPaused]);
|
||||
|
||||
const seekToTime = useCallback((rawSeconds: number) => {
|
||||
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
|
||||
|
||||
if (playerRef.current && duration > 0 && !isSeeking.current) {
|
||||
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
|
||||
|
||||
isSeeking.current = true;
|
||||
|
||||
// iOS optimization: pause while seeking for smoother experience
|
||||
if (Platform.OS === 'ios') {
|
||||
iosWasPausedDuringSeekRef.current = paused;
|
||||
if (!paused) setPaused(true);
|
||||
}
|
||||
|
||||
// Actually perform the seek
|
||||
playerRef.current.seek(timeInSeconds);
|
||||
|
||||
// Debounce the seeking state reset
|
||||
setTimeout(() => {
|
||||
if (isMounted.current && isSeeking.current) {
|
||||
isSeeking.current = false;
|
||||
// Resume if it was playing (iOS specific)
|
||||
if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) {
|
||||
setPaused(false);
|
||||
iosWasPausedDuringSeekRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [duration, paused, setPaused, playerRef, isSeeking, isMounted]);
|
||||
|
||||
const skip = useCallback((seconds: number) => {
|
||||
seekToTime(currentTime + seconds);
|
||||
}, [currentTime, seekToTime]);
|
||||
|
||||
return {
|
||||
togglePlayback,
|
||||
seekToTime,
|
||||
skip,
|
||||
iosWasPausedDuringSeekRef
|
||||
};
|
||||
};
|
||||
38
src/components/player/hooks/usePlayerModals.ts
Normal file
38
src/components/player/hooks/usePlayerModals.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Shared Player Modals Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Episode } from '../../../types/metadata';
|
||||
|
||||
export const usePlayerModals = () => {
|
||||
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
|
||||
const [showSpeedModal, setShowSpeedModal] = useState(false);
|
||||
const [showSourcesModal, setShowSourcesModal] = useState(false);
|
||||
const [showEpisodesModal, setShowEpisodesModal] = useState(false);
|
||||
const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
|
||||
const [showCastDetails, setShowCastDetails] = useState(false);
|
||||
|
||||
// Some modals have associated data
|
||||
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
|
||||
const [errorDetails, setErrorDetails] = useState<string>('');
|
||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
||||
|
||||
return {
|
||||
showAudioModal, setShowAudioModal,
|
||||
showSubtitleModal, setShowSubtitleModal,
|
||||
showSpeedModal, setShowSpeedModal,
|
||||
showSourcesModal, setShowSourcesModal,
|
||||
showEpisodesModal, setShowEpisodesModal,
|
||||
showEpisodeStreamsModal, setShowEpisodeStreamsModal,
|
||||
showErrorModal, setShowErrorModal,
|
||||
showSubtitleLanguageModal, setShowSubtitleLanguageModal,
|
||||
showCastDetails, setShowCastDetails,
|
||||
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
|
||||
errorDetails, setErrorDetails,
|
||||
selectedCastMember, setSelectedCastMember
|
||||
};
|
||||
};
|
||||
117
src/components/player/hooks/usePlayerSetup.ts
Normal file
117
src/components/player/hooks/usePlayerSetup.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Shared Player Setup Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
* Handles StatusBar, orientation, brightness, and app state
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { StatusBar, Dimensions, AppState, InteractionManager, Platform } from 'react-native';
|
||||
import * as Brightness from 'expo-brightness';
|
||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
|
||||
interface PlayerSetupConfig {
|
||||
setScreenDimensions: (dim: any) => void;
|
||||
setVolume: (vol: number) => void;
|
||||
setBrightness: (bri: number) => void;
|
||||
isOpeningAnimationComplete: boolean;
|
||||
}
|
||||
|
||||
export const usePlayerSetup = (config: PlayerSetupConfig) => {
|
||||
const {
|
||||
setScreenDimensions,
|
||||
setVolume,
|
||||
setBrightness,
|
||||
isOpeningAnimationComplete
|
||||
} = config;
|
||||
|
||||
const isAppBackgrounded = useRef(false);
|
||||
|
||||
const enableImmersiveMode = () => {
|
||||
StatusBar.setHidden(true, 'none');
|
||||
};
|
||||
|
||||
const disableImmersiveMode = () => {
|
||||
StatusBar.setHidden(false, 'fade');
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
return () => { };
|
||||
}, [isOpeningAnimationComplete])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial Setup
|
||||
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
|
||||
setScreenDimensions(screen);
|
||||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
});
|
||||
|
||||
StatusBar.setHidden(true, 'none');
|
||||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
|
||||
// Initialize volume (normalized 0-1 for cross-platform)
|
||||
setVolume(1.0);
|
||||
|
||||
// Initialize Brightness
|
||||
const initBrightness = () => {
|
||||
InteractionManager.runAfterInteractions(async () => {
|
||||
try {
|
||||
const currentBrightness = await Brightness.getBrightnessAsync();
|
||||
setBrightness(currentBrightness);
|
||||
} catch (error) {
|
||||
logger.warn('[usePlayerSetup] Error getting initial brightness:', error);
|
||||
setBrightness(1.0);
|
||||
}
|
||||
});
|
||||
};
|
||||
initBrightness();
|
||||
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
disableImmersiveMode();
|
||||
};
|
||||
}, [isOpeningAnimationComplete]);
|
||||
|
||||
// Handle Orientation (Lock to Landscape after opening)
|
||||
useEffect(() => {
|
||||
if (isOpeningAnimationComplete) {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
|
||||
.then(() => {
|
||||
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
|
||||
});
|
||||
});
|
||||
return () => task.cancel();
|
||||
}
|
||||
}, [isOpeningAnimationComplete]);
|
||||
|
||||
// Handle App State
|
||||
useEffect(() => {
|
||||
const onAppStateChange = (state: string) => {
|
||||
if (state === 'active') {
|
||||
isAppBackgrounded.current = false;
|
||||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
} else if (state === 'background' || state === 'inactive') {
|
||||
isAppBackgrounded.current = true;
|
||||
}
|
||||
};
|
||||
const sub = AppState.addEventListener('change', onAppStateChange);
|
||||
return () => sub.remove();
|
||||
}, [isOpeningAnimationComplete]);
|
||||
|
||||
return { isAppBackgrounded };
|
||||
};
|
||||
88
src/components/player/hooks/usePlayerState.ts
Normal file
88
src/components/player/hooks/usePlayerState.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Shared Player State Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import { useState, useRef } from 'react';
|
||||
import { Dimensions, Platform } from 'react-native';
|
||||
|
||||
// Use only resize modes supported by all player backends
|
||||
// (not all players support 'stretch' or 'none')
|
||||
export type PlayerResizeMode = 'contain' | 'cover';
|
||||
|
||||
export const usePlayerState = () => {
|
||||
// Playback State
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [isPlayerReady, setIsPlayerReady] = useState(false);
|
||||
|
||||
// UI State
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [resizeMode, setResizeMode] = useState<PlayerResizeMode>('contain');
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
|
||||
const [is16by9Content, setIs16by9Content] = useState(false);
|
||||
const screenData = Dimensions.get('screen');
|
||||
const [screenDimensions, setScreenDimensions] = useState(screenData);
|
||||
|
||||
// Zoom State
|
||||
const [zoomScale, setZoomScale] = useState(1);
|
||||
const [zoomTranslateX, setZoomTranslateX] = useState(0);
|
||||
const [zoomTranslateY, setZoomTranslateY] = useState(0);
|
||||
const [lastZoomScale, setLastZoomScale] = useState(1);
|
||||
const [lastTranslateX, setLastTranslateX] = useState(0);
|
||||
const [lastTranslateY, setLastTranslateY] = useState(0);
|
||||
|
||||
// AirPlay State (iOS only, but keeping it here for unified interface)
|
||||
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
|
||||
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
|
||||
|
||||
// Logic State
|
||||
const isSeeking = useRef(false);
|
||||
const isDragging = useRef(false);
|
||||
const isMounted = useRef(true);
|
||||
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingSeekValue = useRef<number | null>(null);
|
||||
const lastSeekTime = useRef<number>(0);
|
||||
const wasPlayingBeforeDragRef = useRef<boolean>(false);
|
||||
|
||||
// Helper for iPad/macOS fullscreen
|
||||
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
|
||||
const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
|
||||
const shouldUseFullscreen = isIPad || isMacOS;
|
||||
const windowData = Dimensions.get('window');
|
||||
const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions;
|
||||
|
||||
return {
|
||||
paused, setPaused,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
buffered, setBuffered,
|
||||
isBuffering, setIsBuffering,
|
||||
isVideoLoaded, setIsVideoLoaded,
|
||||
isPlayerReady, setIsPlayerReady,
|
||||
showControls, setShowControls,
|
||||
resizeMode, setResizeMode,
|
||||
videoAspectRatio, setVideoAspectRatio,
|
||||
is16by9Content, setIs16by9Content,
|
||||
screenDimensions, setScreenDimensions,
|
||||
zoomScale, setZoomScale,
|
||||
zoomTranslateX, setZoomTranslateX,
|
||||
zoomTranslateY, setZoomTranslateY,
|
||||
lastZoomScale, setLastZoomScale,
|
||||
lastTranslateX, setLastTranslateX,
|
||||
lastTranslateY, setLastTranslateY,
|
||||
isAirPlayActive, setIsAirPlayActive,
|
||||
allowsAirPlay, setAllowsAirPlay,
|
||||
isSeeking,
|
||||
isDragging,
|
||||
isMounted,
|
||||
seekDebounceTimer,
|
||||
pendingSeekValue,
|
||||
lastSeekTime,
|
||||
wasPlayingBeforeDragRef,
|
||||
effectiveDimensions
|
||||
};
|
||||
};
|
||||
47
src/components/player/hooks/usePlayerTracks.ts
Normal file
47
src/components/player/hooks/usePlayerTracks.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Shared Player Tracks Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { AudioTrack, TextTrack } from '../utils/playerTypes';
|
||||
|
||||
export const usePlayerTracks = () => {
|
||||
// React-native-video style tracks
|
||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
|
||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
|
||||
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
|
||||
|
||||
// KS/VLC style tracks (simpler format)
|
||||
const [ksAudioTracks, setKsAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
||||
const [ksTextTracks, setKsTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
||||
|
||||
// Derived states
|
||||
const hasAudioTracks = audioTracks.length > 0 || ksAudioTracks.length > 0;
|
||||
const hasTextTracks = textTracks.length > 0 || ksTextTracks.length > 0;
|
||||
|
||||
// Track selection functions
|
||||
const selectAudioTrack = useCallback((trackId: number) => {
|
||||
setSelectedAudioTrack(trackId);
|
||||
}, []);
|
||||
|
||||
const selectTextTrack = useCallback((trackId: number) => {
|
||||
setSelectedTextTrack(trackId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Standard tracks
|
||||
audioTracks, setAudioTracks,
|
||||
selectedAudioTrack, setSelectedAudioTrack,
|
||||
textTracks, setTextTracks,
|
||||
selectedTextTrack, setSelectedTextTrack,
|
||||
// KS/VLC tracks
|
||||
ksAudioTracks, setKsAudioTracks,
|
||||
ksTextTracks, setKsTextTracks,
|
||||
// Helpers
|
||||
hasAudioTracks,
|
||||
hasTextTracks,
|
||||
selectAudioTrack,
|
||||
selectTextTrack
|
||||
};
|
||||
};
|
||||
97
src/components/player/hooks/useSpeedControl.ts
Normal file
97
src/components/player/hooks/useSpeedControl.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Shared Speed Control Hook
|
||||
* Used by both Android (VLC) and iOS (KSPlayer) players
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Animated } from 'react-native';
|
||||
import { mmkvStorage } from '../../../services/mmkvStorage';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const SPEED_SETTINGS_KEY = '@nuvio_speed_settings';
|
||||
|
||||
export const useSpeedControl = (initialSpeed: number = 1.0) => {
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState<number>(initialSpeed);
|
||||
const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true);
|
||||
const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0);
|
||||
const [isSpeedBoosted, setIsSpeedBoosted] = useState(false);
|
||||
const [originalSpeed, setOriginalSpeed] = useState<number>(initialSpeed);
|
||||
const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false);
|
||||
|
||||
const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Load Settings
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY);
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved);
|
||||
if (typeof settings.holdToSpeedEnabled === 'boolean') setHoldToSpeedEnabled(settings.holdToSpeedEnabled);
|
||||
if (typeof settings.holdToSpeedValue === 'number') setHoldToSpeedValue(settings.holdToSpeedValue);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('[useSpeedControl] Error loading settings', e);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Save Settings
|
||||
useEffect(() => {
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({
|
||||
holdToSpeedEnabled,
|
||||
holdToSpeedValue
|
||||
}));
|
||||
} catch (e) { }
|
||||
};
|
||||
saveSettings();
|
||||
}, [holdToSpeedEnabled, holdToSpeedValue]);
|
||||
|
||||
const activateSpeedBoost = useCallback(() => {
|
||||
if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return;
|
||||
|
||||
setOriginalSpeed(playbackSpeed);
|
||||
setPlaybackSpeed(holdToSpeedValue);
|
||||
setIsSpeedBoosted(true);
|
||||
setShowSpeedActivatedOverlay(true);
|
||||
|
||||
Animated.timing(speedActivatedOverlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true
|
||||
}).start();
|
||||
|
||||
setTimeout(() => {
|
||||
Animated.timing(speedActivatedOverlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
}).start(() => setShowSpeedActivatedOverlay(false));
|
||||
}, 2000);
|
||||
|
||||
}, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]);
|
||||
|
||||
const deactivateSpeedBoost = useCallback(() => {
|
||||
if (isSpeedBoosted) {
|
||||
setPlaybackSpeed(originalSpeed);
|
||||
setIsSpeedBoosted(false);
|
||||
Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start();
|
||||
}
|
||||
}, [isSpeedBoosted, originalSpeed]);
|
||||
|
||||
return {
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
holdToSpeedEnabled,
|
||||
setHoldToSpeedEnabled,
|
||||
holdToSpeedValue,
|
||||
setHoldToSpeedValue,
|
||||
isSpeedBoosted,
|
||||
activateSpeedBoost,
|
||||
deactivateSpeedBoost,
|
||||
showSpeedActivatedOverlay,
|
||||
speedActivatedOverlayOpacity
|
||||
};
|
||||
};
|
||||
333
src/components/player/ios/components/GestureControls.tsx
Normal file
333
src/components/player/ios/components/GestureControls.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Animated } from 'react-native';
|
||||
import {
|
||||
TapGestureHandler,
|
||||
PanGestureHandler,
|
||||
LongPressGestureHandler,
|
||||
} from 'react-native-gesture-handler';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
||||
interface GestureControlsProps {
|
||||
screenDimensions: { width: number, height: number };
|
||||
gestureControls: any;
|
||||
onLongPressActivated: () => void;
|
||||
onLongPressEnd: () => void;
|
||||
onLongPressStateChange: (event: any) => void;
|
||||
toggleControls: () => void;
|
||||
showControls: boolean;
|
||||
hideControls: () => void;
|
||||
volume: number;
|
||||
brightness: number;
|
||||
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
}
|
||||
|
||||
export const GestureControls: React.FC<GestureControlsProps> = ({
|
||||
screenDimensions,
|
||||
gestureControls,
|
||||
onLongPressActivated,
|
||||
onLongPressEnd,
|
||||
onLongPressStateChange,
|
||||
toggleControls,
|
||||
showControls,
|
||||
hideControls,
|
||||
volume,
|
||||
brightness,
|
||||
controlsTimeout
|
||||
}) => {
|
||||
// Helper to get dimensions (using passed screenDimensions)
|
||||
const getDimensions = () => screenDimensions;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left side gesture handler - brightness + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Right side gesture handler - volume + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 5,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Volume Overlay */}
|
||||
{gestureControls.showVolumeOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: getDimensions().width / 2 - 60,
|
||||
top: getDimensions().height / 2 - 60,
|
||||
opacity: gestureControls.volumeOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<MaterialIcons
|
||||
name={volume === 0 ? "volume-off" : volume < 30 ? "volume-mute" : volume < 70 ? "volume-down" : "volume-up"}
|
||||
size={24}
|
||||
color={volume === 0 ? "#FF6B6B" : "#FFFFFF"}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Horizontal Dotted Progress Bar */}
|
||||
<View style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* Dotted background */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 1,
|
||||
}}>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 0.75,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Progress fill */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${volume}%`,
|
||||
height: 6,
|
||||
backgroundColor: volume === 0 ? '#FF6B6B' : '#E50914',
|
||||
borderRadius: 3,
|
||||
shadowColor: volume === 0 ? '#FF6B6B' : '#E50914',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 2,
|
||||
}} />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{Math.round(volume)}%
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Brightness Overlay */}
|
||||
{gestureControls.showBrightnessOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: getDimensions().width / 2 - 60,
|
||||
top: getDimensions().height / 2 - 60,
|
||||
opacity: gestureControls.brightnessOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<MaterialIcons
|
||||
name={brightness < 0.2 ? "brightness-low" : brightness < 0.5 ? "brightness-medium" : brightness < 0.8 ? "brightness-high" : "brightness-auto"}
|
||||
size={24}
|
||||
color={brightness < 0.2 ? "#FFD700" : "#FFFFFF"}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Horizontal Dotted Progress Bar */}
|
||||
<View style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* Dotted background */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 1,
|
||||
}}>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 0.75,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Progress fill */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${brightness * 100}%`,
|
||||
height: 6,
|
||||
backgroundColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
|
||||
borderRadius: 3,
|
||||
shadowColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 2,
|
||||
}} />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{Math.round(brightness * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
140
src/components/player/ios/components/KSPlayerSurface.tsx
Normal file
140
src/components/player/ios/components/KSPlayerSurface.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Animated } from 'react-native';
|
||||
import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
|
||||
import KSPlayerComponent, { KSPlayerRef, KSPlayerSource } from '../../KSPlayerComponent';
|
||||
|
||||
interface KSPlayerSurfaceProps {
|
||||
ksPlayerRef: React.RefObject<KSPlayerRef>;
|
||||
uri: string;
|
||||
headers?: Record<string, string>;
|
||||
paused: boolean;
|
||||
volume: number;
|
||||
playbackSpeed: number;
|
||||
resizeMode: 'contain' | 'cover' | 'stretch';
|
||||
zoomScale: number;
|
||||
setZoomScale: (scale: number) => void;
|
||||
lastZoomScale: number;
|
||||
setLastZoomScale: (scale: number) => void;
|
||||
|
||||
// Tracks - use number directly
|
||||
audioTrack?: number;
|
||||
textTrack?: number;
|
||||
onAudioTracks: (data: any) => void;
|
||||
onTextTracks: (data: any) => void;
|
||||
|
||||
// Handlers
|
||||
onLoad: (data: any) => void;
|
||||
onProgress: (data: any) => void;
|
||||
onEnd: () => void;
|
||||
onError: (error: any) => void;
|
||||
onBuffer: (isBuffering: boolean) => void;
|
||||
onReadyForDisplay: () => void;
|
||||
onPlaybackStalled: () => void;
|
||||
onPlaybackResume: () => void;
|
||||
|
||||
// Dimensions
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
customVideoStyles: any;
|
||||
}
|
||||
|
||||
export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
|
||||
ksPlayerRef,
|
||||
uri,
|
||||
headers,
|
||||
paused,
|
||||
volume,
|
||||
playbackSpeed,
|
||||
resizeMode,
|
||||
zoomScale,
|
||||
setZoomScale,
|
||||
lastZoomScale,
|
||||
setLastZoomScale,
|
||||
audioTrack,
|
||||
textTrack,
|
||||
onAudioTracks,
|
||||
onTextTracks,
|
||||
onLoad,
|
||||
onProgress,
|
||||
onEnd,
|
||||
onError,
|
||||
onBuffer,
|
||||
onReadyForDisplay,
|
||||
onPlaybackStalled,
|
||||
onPlaybackResume,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
customVideoStyles
|
||||
}) => {
|
||||
const pinchRef = useRef<PinchGestureHandler>(null);
|
||||
|
||||
const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
|
||||
const { scale } = event.nativeEvent;
|
||||
// Limit max zoom to 1.1x as per original logic, min 1
|
||||
const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
|
||||
setZoomScale(newScale);
|
||||
};
|
||||
|
||||
const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
|
||||
if (event.nativeEvent.state === State.END) {
|
||||
setLastZoomScale(zoomScale);
|
||||
}
|
||||
};
|
||||
|
||||
// Create source object for KSPlayerComponent
|
||||
const source: KSPlayerSource = {
|
||||
uri,
|
||||
headers
|
||||
};
|
||||
|
||||
// Handle buffering - KSPlayerComponent uses onBuffering callback
|
||||
const handleBuffering = (data: any) => {
|
||||
onBuffer(data?.isBuffering ?? false);
|
||||
};
|
||||
|
||||
// Handle load - also extract tracks if available
|
||||
const handleLoad = (data: any) => {
|
||||
onLoad(data);
|
||||
// Extract tracks if present in load data
|
||||
if (data?.audioTracks) {
|
||||
onAudioTracks({ audioTracks: data.audioTracks });
|
||||
}
|
||||
if (data?.textTracks) {
|
||||
onTextTracks({ textTracks: data.textTracks });
|
||||
}
|
||||
// Notify ready for display
|
||||
onReadyForDisplay();
|
||||
};
|
||||
|
||||
return (
|
||||
<PinchGestureHandler
|
||||
ref={pinchRef}
|
||||
onGestureEvent={onPinchGestureEvent}
|
||||
onHandlerStateChange={onPinchHandlerStateChange}
|
||||
>
|
||||
<Animated.View style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform: [{ scale: zoomScale }]
|
||||
}}>
|
||||
<KSPlayerComponent
|
||||
ref={ksPlayerRef}
|
||||
source={source}
|
||||
paused={paused}
|
||||
volume={volume}
|
||||
rate={playbackSpeed}
|
||||
resizeMode={resizeMode}
|
||||
audioTrack={audioTrack}
|
||||
textTrack={textTrack}
|
||||
onLoad={handleLoad}
|
||||
onProgress={onProgress}
|
||||
onBuffering={handleBuffering}
|
||||
onEnd={onEnd}
|
||||
onError={onError}
|
||||
style={customVideoStyles.width ? customVideoStyles : { width: screenWidth, height: screenHeight }}
|
||||
/>
|
||||
</Animated.View>
|
||||
</PinchGestureHandler>
|
||||
);
|
||||
};
|
||||
228
src/components/player/ios/components/PauseOverlay.tsx
Normal file
228
src/components/player/ios/components/PauseOverlay.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
interface PauseOverlayProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
episodeTitle?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
year?: string | number;
|
||||
type: string;
|
||||
description: string;
|
||||
cast: any[];
|
||||
screenDimensions: { width: number, height: number };
|
||||
}
|
||||
|
||||
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
title,
|
||||
episodeTitle,
|
||||
season,
|
||||
episode,
|
||||
year,
|
||||
type,
|
||||
description,
|
||||
cast,
|
||||
screenDimensions
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Internal Animation State
|
||||
const pauseOverlayOpacity = useRef(new Animated.Value(visible ? 1 : 0)).current;
|
||||
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
|
||||
const metadataOpacity = useRef(new Animated.Value(1)).current;
|
||||
const metadataScale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Cast Details State
|
||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
||||
const [showCastDetails, setShowCastDetails] = useState(false);
|
||||
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
|
||||
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
Animated.timing(pauseOverlayOpacity, {
|
||||
toValue: visible ? 1 : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true
|
||||
}).start();
|
||||
}, [visible]);
|
||||
|
||||
if (!visible && !showCastDetails) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
opacity: pauseOverlayOpacity,
|
||||
}}
|
||||
>
|
||||
{/* Horizontal Fade */}
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
|
||||
locations={[0, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</View>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.6)',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.2)',
|
||||
'rgba(0,0,0,0.0)'
|
||||
]}
|
||||
locations={[0, 0.3, 0.6, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<Animated.View style={{
|
||||
position: 'absolute',
|
||||
left: 24 + insets.left,
|
||||
right: 24 + insets.right,
|
||||
top: 24 + insets.top,
|
||||
bottom: 110 + insets.bottom,
|
||||
transform: [{ translateY: pauseOverlayTranslateY }]
|
||||
}}>
|
||||
{showCastDetails && selectedCastMember ? (
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
opacity: castDetailsOpacity,
|
||||
transform: [{ scale: castDetailsScale }]
|
||||
}}
|
||||
>
|
||||
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
|
||||
<TouchableOpacity
|
||||
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
|
||||
onPress={() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
|
||||
]).start(() => {
|
||||
setShowCastDetails(false);
|
||||
setSelectedCastMember(null);
|
||||
Animated.parallel([
|
||||
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
|
||||
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
|
||||
]).start();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
|
||||
{selectedCastMember.profile_path && (
|
||||
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
|
||||
<FastImage
|
||||
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
|
||||
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flex: 1, paddingTop: 8 }}>
|
||||
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
|
||||
{selectedCastMember.name}
|
||||
</Text>
|
||||
{selectedCastMember.character && (
|
||||
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
|
||||
as {selectedCastMember.character}
|
||||
</Text>
|
||||
)}
|
||||
{selectedCastMember.biography && (
|
||||
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
|
||||
{selectedCastMember.biography}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
|
||||
<View>
|
||||
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
|
||||
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
{!!year && (
|
||||
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
|
||||
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
|
||||
</Text>
|
||||
)}
|
||||
{!!episodeTitle && (
|
||||
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
|
||||
{episodeTitle}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{cast && cast.length > 0 && (
|
||||
<View style={{ marginTop: 16 }}>
|
||||
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||
{cast.slice(0, 6).map((castMember: any, index: number) => (
|
||||
<TouchableOpacity
|
||||
key={castMember.id || index}
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
|
||||
onPress={() => {
|
||||
setSelectedCastMember(castMember);
|
||||
Animated.parallel([
|
||||
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
|
||||
]).start(() => {
|
||||
setShowCastDetails(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
|
||||
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
|
||||
]).start();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
|
||||
{castMember.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Animated } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { styles } from '../../utils/playerStyles';
|
||||
|
||||
interface SpeedActivatedOverlayProps {
|
||||
visible: boolean;
|
||||
opacity: Animated.Value;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
|
||||
visible,
|
||||
opacity,
|
||||
speed
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.speedActivatedOverlay,
|
||||
{ opacity: opacity }
|
||||
]}
|
||||
>
|
||||
<View style={styles.speedActivatedContainer}>
|
||||
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
|
||||
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
58
src/components/player/ios/hooks/useCustomSubtitles.ts
Normal file
58
src/components/player/ios/hooks/useCustomSubtitles.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
DEFAULT_SUBTITLE_SIZE,
|
||||
SubtitleCue,
|
||||
SubtitleSegment,
|
||||
WyzieSubtitle
|
||||
} from '../../utils/playerTypes';
|
||||
|
||||
export const useCustomSubtitles = () => {
|
||||
// Data State
|
||||
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
||||
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
||||
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
|
||||
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
|
||||
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
|
||||
|
||||
// Loading State
|
||||
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
|
||||
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
|
||||
|
||||
// Styling State
|
||||
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
|
||||
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
|
||||
const [subtitleTextColor, setSubtitleTextColor] = useState<string>('#FFFFFF');
|
||||
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState<number>(0.7);
|
||||
const [subtitleTextShadow, setSubtitleTextShadow] = useState<boolean>(true);
|
||||
const [subtitleOutline, setSubtitleOutline] = useState<boolean>(true);
|
||||
const [subtitleOutlineColor, setSubtitleOutlineColor] = useState<string>('#000000');
|
||||
const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState<number>(4);
|
||||
const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center');
|
||||
const [subtitleBottomOffset, setSubtitleBottomOffset] = useState<number>(10);
|
||||
const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState<number>(0);
|
||||
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState<number>(1.2);
|
||||
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState<number>(0);
|
||||
|
||||
return {
|
||||
customSubtitles, setCustomSubtitles,
|
||||
currentSubtitle, setCurrentSubtitle,
|
||||
currentFormattedSegments, setCurrentFormattedSegments,
|
||||
availableSubtitles, setAvailableSubtitles,
|
||||
useCustomSubtitles, setUseCustomSubtitles,
|
||||
isLoadingSubtitles, setIsLoadingSubtitles,
|
||||
isLoadingSubtitleList, setIsLoadingSubtitleList,
|
||||
subtitleSize, setSubtitleSize,
|
||||
subtitleBackground, setSubtitleBackground,
|
||||
subtitleTextColor, setSubtitleTextColor,
|
||||
subtitleBgOpacity, setSubtitleBgOpacity,
|
||||
subtitleTextShadow, setSubtitleTextShadow,
|
||||
subtitleOutline, setSubtitleOutline,
|
||||
subtitleOutlineColor, setSubtitleOutlineColor,
|
||||
subtitleOutlineWidth, setSubtitleOutlineWidth,
|
||||
subtitleAlign, setSubtitleAlign,
|
||||
subtitleBottomOffset, setSubtitleBottomOffset,
|
||||
subtitleLetterSpacing, setSubtitleLetterSpacing,
|
||||
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier,
|
||||
subtitleOffsetSec, setSubtitleOffsetSec
|
||||
};
|
||||
};
|
||||
15
src/components/player/ios/hooks/useKSPlayer.ts
Normal file
15
src/components/player/ios/hooks/useKSPlayer.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useRef } from 'react';
|
||||
import { KSPlayerRef } from '../../KSPlayerComponent';
|
||||
|
||||
export const useKSPlayer = () => {
|
||||
const ksPlayerRef = useRef<KSPlayerRef>(null);
|
||||
|
||||
const seek = (time: number) => {
|
||||
ksPlayerRef.current?.seek(time);
|
||||
};
|
||||
|
||||
return {
|
||||
ksPlayerRef,
|
||||
seek
|
||||
};
|
||||
};
|
||||
58
src/components/player/ios/hooks/useNextEpisode.ts
Normal file
58
src/components/player/ios/hooks/useNextEpisode.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
export const useNextEpisode = (
|
||||
type: string | undefined,
|
||||
season: number | undefined,
|
||||
episode: number | undefined,
|
||||
groupedEpisodes: any,
|
||||
metadataGroupedEpisodes: any,
|
||||
episodeId: string | undefined
|
||||
) => {
|
||||
// Current description
|
||||
const currentEpisodeDescription = useMemo(() => {
|
||||
try {
|
||||
if (type !== 'series') return '';
|
||||
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
|
||||
if (!allEpisodes || allEpisodes.length === 0) return '';
|
||||
|
||||
let match: any | null = null;
|
||||
if (episodeId) {
|
||||
match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId));
|
||||
}
|
||||
if (!match && season && episode) {
|
||||
match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode);
|
||||
}
|
||||
return (match?.overview || '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [type, groupedEpisodes, episodeId, season, episode]);
|
||||
|
||||
// Next Episode
|
||||
const nextEpisode = useMemo(() => {
|
||||
try {
|
||||
if (type !== 'series' || !season || !episode) return null;
|
||||
const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0
|
||||
? groupedEpisodes
|
||||
: (metadataGroupedEpisodes || {});
|
||||
|
||||
const allEpisodes = Object.values(sourceGroups || {}).flat() as any[];
|
||||
if (!allEpisodes || allEpisodes.length === 0) return null;
|
||||
|
||||
let nextEp = allEpisodes.find((ep: any) =>
|
||||
ep.season_number === season && ep.episode_number === episode + 1
|
||||
);
|
||||
|
||||
if (!nextEp) {
|
||||
nextEp = allEpisodes.find((ep: any) =>
|
||||
ep.season_number === season + 1 && ep.episode_number === 1
|
||||
);
|
||||
}
|
||||
return nextEp;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]);
|
||||
|
||||
return { currentEpisodeDescription, nextEpisode };
|
||||
};
|
||||
149
src/components/player/ios/hooks/useOpeningAnimation.ts
Normal file
149
src/components/player/ios/hooks/useOpeningAnimation.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Animated, InteractionManager } from 'react-native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
|
||||
export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => {
|
||||
// Animation Values
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
const openingFadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const openingScaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
const backgroundFadeAnim = useRef(new Animated.Value(1)).current;
|
||||
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
|
||||
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
|
||||
const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false);
|
||||
const [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
|
||||
|
||||
// Prefetch Background
|
||||
useEffect(() => {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
if (backdrop && typeof backdrop === 'string') {
|
||||
setIsBackdropLoaded(false);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
try {
|
||||
FastImage.preload([{ uri: backdrop }]);
|
||||
setIsBackdropLoaded(true);
|
||||
Animated.timing(backdropImageOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} catch (error) {
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(1);
|
||||
}
|
||||
} else {
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
}
|
||||
});
|
||||
return () => task.cancel();
|
||||
}, [backdrop]);
|
||||
|
||||
// Prefetch Logo
|
||||
useEffect(() => {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
const logoUrl = metadata?.logo;
|
||||
if (logoUrl && typeof logoUrl === 'string') {
|
||||
try {
|
||||
FastImage.preload([{ uri: logoUrl }]);
|
||||
} catch (error) { }
|
||||
}
|
||||
});
|
||||
return () => task.cancel();
|
||||
}, [metadata]);
|
||||
|
||||
const startOpeningAnimation = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(logoOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(logoScaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
const createPulseAnimation = () => {
|
||||
return Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const loopPulse = () => {
|
||||
createPulseAnimation().start(() => {
|
||||
if (!isOpeningAnimationComplete) {
|
||||
loopPulse();
|
||||
}
|
||||
});
|
||||
};
|
||||
loopPulse();
|
||||
};
|
||||
|
||||
const completeOpeningAnimation = () => {
|
||||
pulseAnim.stopAnimation();
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(openingFadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(openingScaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 350,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backgroundFadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setIsOpeningAnimationComplete(true);
|
||||
setTimeout(() => {
|
||||
setShouldHideOpeningOverlay(true);
|
||||
}, 450);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isOpeningAnimationComplete) {
|
||||
// logger.warn('[VideoPlayer] Opening animation fallback triggered');
|
||||
setIsOpeningAnimationComplete(true);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return {
|
||||
fadeAnim,
|
||||
openingFadeAnim,
|
||||
openingScaleAnim,
|
||||
backgroundFadeAnim,
|
||||
backdropImageOpacityAnim,
|
||||
logoScaleAnim,
|
||||
logoOpacityAnim,
|
||||
pulseAnim,
|
||||
isOpeningAnimationComplete,
|
||||
shouldHideOpeningOverlay,
|
||||
isBackdropLoaded,
|
||||
startOpeningAnimation,
|
||||
completeOpeningAnimation
|
||||
};
|
||||
};
|
||||
63
src/components/player/ios/hooks/usePlayerControls.ts
Normal file
63
src/components/player/ios/hooks/usePlayerControls.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useRef, useCallback } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
|
||||
const DEBUG_MODE = false;
|
||||
const END_EPSILON = 0.3;
|
||||
|
||||
export const usePlayerControls = (
|
||||
ksPlayerRef: any,
|
||||
paused: boolean,
|
||||
setPaused: (paused: boolean) => void,
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
isSeeking: React.MutableRefObject<boolean>,
|
||||
isMounted: React.MutableRefObject<boolean>
|
||||
) => {
|
||||
// iOS seeking helpers
|
||||
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
|
||||
|
||||
const togglePlayback = useCallback(() => {
|
||||
setPaused(!paused);
|
||||
}, [paused, setPaused]);
|
||||
|
||||
const seekToTime = useCallback((rawSeconds: number) => {
|
||||
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
|
||||
|
||||
if (ksPlayerRef.current && duration > 0 && !isSeeking.current) {
|
||||
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
|
||||
|
||||
isSeeking.current = true;
|
||||
|
||||
// iOS optimization: pause while seeking for smoother experience
|
||||
iosWasPausedDuringSeekRef.current = paused;
|
||||
if (!paused) setPaused(true);
|
||||
|
||||
// Actually perform the seek
|
||||
ksPlayerRef.current.seek(timeInSeconds);
|
||||
|
||||
// Debounce the seeking state reset
|
||||
setTimeout(() => {
|
||||
if (isMounted.current && isSeeking.current) {
|
||||
isSeeking.current = false;
|
||||
// Resume if it was playing
|
||||
if (iosWasPausedDuringSeekRef.current === false) {
|
||||
setPaused(false);
|
||||
iosWasPausedDuringSeekRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [duration, paused, setPaused, ksPlayerRef, isSeeking, isMounted]);
|
||||
|
||||
const skip = useCallback((seconds: number) => {
|
||||
seekToTime(currentTime + seconds);
|
||||
}, [currentTime, seekToTime]);
|
||||
|
||||
return {
|
||||
togglePlayback,
|
||||
seekToTime,
|
||||
skip,
|
||||
iosWasPausedDuringSeekRef
|
||||
};
|
||||
};
|
||||
34
src/components/player/ios/hooks/usePlayerModals.ts
Normal file
34
src/components/player/ios/hooks/usePlayerModals.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useState } from 'react';
|
||||
import { Episode } from '../../../../types/metadata';
|
||||
|
||||
export const usePlayerModals = () => {
|
||||
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
|
||||
const [showSpeedModal, setShowSpeedModal] = useState(false);
|
||||
const [showSourcesModal, setShowSourcesModal] = useState(false);
|
||||
const [showEpisodesModal, setShowEpisodesModal] = useState(false);
|
||||
const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
|
||||
const [showCastDetails, setShowCastDetails] = useState(false);
|
||||
|
||||
// Some modals have associated data
|
||||
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
|
||||
const [errorDetails, setErrorDetails] = useState<string>('');
|
||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
||||
|
||||
return {
|
||||
showAudioModal, setShowAudioModal,
|
||||
showSubtitleModal, setShowSubtitleModal,
|
||||
showSpeedModal, setShowSpeedModal,
|
||||
showSourcesModal, setShowSourcesModal,
|
||||
showEpisodesModal, setShowEpisodesModal,
|
||||
showEpisodeStreamsModal, setShowEpisodeStreamsModal,
|
||||
showErrorModal, setShowErrorModal,
|
||||
showSubtitleLanguageModal, setShowSubtitleLanguageModal,
|
||||
showCastDetails, setShowCastDetails,
|
||||
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
|
||||
errorDetails, setErrorDetails,
|
||||
selectedCastMember, setSelectedCastMember
|
||||
};
|
||||
};
|
||||
103
src/components/player/ios/hooks/usePlayerSetup.ts
Normal file
103
src/components/player/ios/hooks/usePlayerSetup.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { StatusBar, Dimensions, AppState, InteractionManager } from 'react-native';
|
||||
import * as Brightness from 'expo-brightness';
|
||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
|
||||
export const usePlayerSetup = (
|
||||
setScreenDimensions: (dim: any) => void,
|
||||
setVolume: (vol: number) => void,
|
||||
setBrightness: (bri: number) => void,
|
||||
isOpeningAnimationComplete: boolean
|
||||
) => {
|
||||
const isAppBackgrounded = useRef(false);
|
||||
|
||||
const enableImmersiveMode = () => {
|
||||
StatusBar.setHidden(true, 'none');
|
||||
};
|
||||
|
||||
const disableImmersiveMode = () => {
|
||||
StatusBar.setHidden(false, 'fade');
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
return () => { };
|
||||
}, [isOpeningAnimationComplete])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial Setup
|
||||
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
|
||||
setScreenDimensions(screen);
|
||||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
});
|
||||
|
||||
StatusBar.setHidden(true, 'none');
|
||||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
|
||||
// Initialize volume (KSPlayer uses 0-100)
|
||||
setVolume(100);
|
||||
|
||||
// Initialize Brightness
|
||||
const initBrightness = () => {
|
||||
InteractionManager.runAfterInteractions(async () => {
|
||||
try {
|
||||
const currentBrightness = await Brightness.getBrightnessAsync();
|
||||
setBrightness(currentBrightness);
|
||||
} catch (error) {
|
||||
logger.warn('[usePlayerSetup] Error getting initial brightness:', error);
|
||||
setBrightness(1.0);
|
||||
}
|
||||
});
|
||||
};
|
||||
initBrightness();
|
||||
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
disableImmersiveMode();
|
||||
};
|
||||
}, [isOpeningAnimationComplete]);
|
||||
|
||||
// Handle Orientation (Lock to Landscape after opening)
|
||||
useEffect(() => {
|
||||
if (isOpeningAnimationComplete) {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
|
||||
.then(() => {
|
||||
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
|
||||
});
|
||||
});
|
||||
return () => task.cancel();
|
||||
}
|
||||
}, [isOpeningAnimationComplete]);
|
||||
|
||||
// Handle App State
|
||||
useEffect(() => {
|
||||
const onAppStateChange = (state: string) => {
|
||||
if (state === 'active') {
|
||||
isAppBackgrounded.current = false;
|
||||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
} else if (state === 'background' || state === 'inactive') {
|
||||
isAppBackgrounded.current = true;
|
||||
}
|
||||
};
|
||||
const sub = AppState.addEventListener('change', onAppStateChange);
|
||||
return () => sub.remove();
|
||||
}, [isOpeningAnimationComplete]);
|
||||
|
||||
return { isAppBackgrounded };
|
||||
};
|
||||
83
src/components/player/ios/hooks/usePlayerState.ts
Normal file
83
src/components/player/ios/hooks/usePlayerState.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { Dimensions, Platform } from 'react-native';
|
||||
|
||||
// Use a specific type for resizeMode that matches what KSPlayerComponent supports
|
||||
type PlayerResizeMode = 'contain' | 'cover' | 'stretch';
|
||||
|
||||
export const usePlayerState = () => {
|
||||
// Playback State
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [isPlayerReady, setIsPlayerReady] = useState(false);
|
||||
|
||||
// UI State
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [resizeMode, setResizeMode] = useState<PlayerResizeMode>('contain');
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
|
||||
const [is16by9Content, setIs16by9Content] = useState(false);
|
||||
const screenData = Dimensions.get('screen');
|
||||
const [screenDimensions, setScreenDimensions] = useState(screenData);
|
||||
|
||||
// Zoom State
|
||||
const [zoomScale, setZoomScale] = useState(1);
|
||||
const [zoomTranslateX, setZoomTranslateX] = useState(0);
|
||||
const [zoomTranslateY, setZoomTranslateY] = useState(0);
|
||||
const [lastZoomScale, setLastZoomScale] = useState(1);
|
||||
const [lastTranslateX, setLastTranslateX] = useState(0);
|
||||
const [lastTranslateY, setLastTranslateY] = useState(0);
|
||||
|
||||
// AirPlay State
|
||||
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
|
||||
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
|
||||
|
||||
// Logic State
|
||||
const isSeeking = useRef(false);
|
||||
const isDragging = useRef(false);
|
||||
const isMounted = useRef(true);
|
||||
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingSeekValue = useRef<number | null>(null);
|
||||
const lastSeekTime = useRef<number>(0);
|
||||
const wasPlayingBeforeDragRef = useRef<boolean>(false);
|
||||
|
||||
// Helper for iPad/macOS fullscreen
|
||||
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
|
||||
const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
|
||||
const shouldUseFullscreen = isIPad || isMacOS;
|
||||
const windowData = Dimensions.get('window');
|
||||
const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions;
|
||||
|
||||
return {
|
||||
paused, setPaused,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
buffered, setBuffered,
|
||||
isBuffering, setIsBuffering,
|
||||
isVideoLoaded, setIsVideoLoaded,
|
||||
isPlayerReady, setIsPlayerReady,
|
||||
showControls, setShowControls,
|
||||
resizeMode, setResizeMode,
|
||||
videoAspectRatio, setVideoAspectRatio,
|
||||
is16by9Content, setIs16by9Content,
|
||||
screenDimensions, setScreenDimensions,
|
||||
zoomScale, setZoomScale,
|
||||
zoomTranslateX, setZoomTranslateX,
|
||||
zoomTranslateY, setZoomTranslateY,
|
||||
lastZoomScale, setLastZoomScale,
|
||||
lastTranslateX, setLastTranslateX,
|
||||
lastTranslateY, setLastTranslateY,
|
||||
isAirPlayActive, setIsAirPlayActive,
|
||||
allowsAirPlay, setAllowsAirPlay,
|
||||
isSeeking,
|
||||
isDragging,
|
||||
isMounted,
|
||||
seekDebounceTimer,
|
||||
pendingSeekValue,
|
||||
lastSeekTime,
|
||||
wasPlayingBeforeDragRef,
|
||||
effectiveDimensions
|
||||
};
|
||||
};
|
||||
38
src/components/player/ios/hooks/usePlayerTracks.ts
Normal file
38
src/components/player/ios/hooks/usePlayerTracks.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { AudioTrack, TextTrack } from '../../utils/playerTypes';
|
||||
|
||||
export const usePlayerTracks = () => {
|
||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
|
||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
|
||||
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
|
||||
|
||||
const [ksAudioTracks, setKsAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
||||
const [ksTextTracks, setKsTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
||||
|
||||
// Derived states or logic
|
||||
const hasAudioTracks = audioTracks.length > 0;
|
||||
const hasTextTracks = textTracks.length > 0;
|
||||
|
||||
// Track selection functions
|
||||
const selectAudioTrack = useCallback((trackId: number) => {
|
||||
setSelectedAudioTrack(trackId);
|
||||
}, []);
|
||||
|
||||
const selectTextTrack = useCallback((trackId: number) => {
|
||||
setSelectedTextTrack(trackId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
audioTracks, setAudioTracks,
|
||||
selectedAudioTrack, setSelectedAudioTrack,
|
||||
textTracks, setTextTracks,
|
||||
selectedTextTrack, setSelectedTextTrack,
|
||||
ksAudioTracks, setKsAudioTracks,
|
||||
ksTextTracks, setKsTextTracks,
|
||||
hasAudioTracks,
|
||||
hasTextTracks,
|
||||
selectAudioTrack,
|
||||
selectTextTrack
|
||||
};
|
||||
};
|
||||
93
src/components/player/ios/hooks/useSpeedControl.ts
Normal file
93
src/components/player/ios/hooks/useSpeedControl.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Animated } from 'react-native';
|
||||
import { mmkvStorage } from '../../../../services/mmkvStorage';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
|
||||
const SPEED_SETTINGS_KEY = '@nuvio_speed_settings';
|
||||
|
||||
export const useSpeedControl = (initialSpeed: number = 1.0) => {
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState<number>(initialSpeed);
|
||||
const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true);
|
||||
const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0);
|
||||
const [isSpeedBoosted, setIsSpeedBoosted] = useState(false);
|
||||
const [originalSpeed, setOriginalSpeed] = useState<number>(initialSpeed);
|
||||
const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false);
|
||||
|
||||
const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Load Settings
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY);
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved);
|
||||
if (typeof settings.holdToSpeedEnabled === 'boolean') setHoldToSpeedEnabled(settings.holdToSpeedEnabled);
|
||||
if (typeof settings.holdToSpeedValue === 'number') setHoldToSpeedValue(settings.holdToSpeedValue);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('[useSpeedControl] Error loading settings', e);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Save Settings
|
||||
useEffect(() => {
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({
|
||||
holdToSpeedEnabled,
|
||||
holdToSpeedValue
|
||||
}));
|
||||
} catch (e) { }
|
||||
};
|
||||
saveSettings();
|
||||
}, [holdToSpeedEnabled, holdToSpeedValue]);
|
||||
|
||||
const activateSpeedBoost = useCallback(() => {
|
||||
if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return;
|
||||
|
||||
setOriginalSpeed(playbackSpeed);
|
||||
setPlaybackSpeed(holdToSpeedValue);
|
||||
setIsSpeedBoosted(true);
|
||||
setShowSpeedActivatedOverlay(true);
|
||||
|
||||
Animated.timing(speedActivatedOverlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true
|
||||
}).start();
|
||||
|
||||
setTimeout(() => {
|
||||
Animated.timing(speedActivatedOverlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
}).start(() => setShowSpeedActivatedOverlay(false));
|
||||
}, 2000);
|
||||
|
||||
}, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]);
|
||||
|
||||
const deactivateSpeedBoost = useCallback(() => {
|
||||
if (isSpeedBoosted) {
|
||||
setPlaybackSpeed(originalSpeed);
|
||||
setIsSpeedBoosted(false);
|
||||
Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start();
|
||||
}
|
||||
}, [isSpeedBoosted, originalSpeed]);
|
||||
|
||||
return {
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
holdToSpeedEnabled,
|
||||
setHoldToSpeedEnabled,
|
||||
holdToSpeedValue,
|
||||
setHoldToSpeedValue,
|
||||
isSpeedBoosted,
|
||||
activateSpeedBoost,
|
||||
deactivateSpeedBoost,
|
||||
showSpeedActivatedOverlay,
|
||||
speedActivatedOverlayOpacity
|
||||
};
|
||||
};
|
||||
120
src/components/player/ios/hooks/useWatchProgress.ts
Normal file
120
src/components/player/ios/hooks/useWatchProgress.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { storageService } from '../../../../services/storageService';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
import { useSettings } from '../../../../hooks/useSettings';
|
||||
|
||||
export const useWatchProgress = (
|
||||
id: string | undefined,
|
||||
type: string | undefined,
|
||||
episodeId: string | undefined,
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
paused: boolean,
|
||||
traktAutosync: any,
|
||||
seekToTime: (time: number) => void
|
||||
) => {
|
||||
const [resumePosition, setResumePosition] = useState<number | null>(null);
|
||||
const [savedDuration, setSavedDuration] = useState<number | null>(null);
|
||||
const [initialPosition, setInitialPosition] = useState<number | null>(null);
|
||||
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
|
||||
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { settings: appSettings } = useSettings();
|
||||
const initialSeekTargetRef = useRef<number | null>(null);
|
||||
|
||||
// Values refs for unmount cleanup
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
const durationRef = useRef(duration);
|
||||
|
||||
useEffect(() => {
|
||||
currentTimeRef.current = currentTime;
|
||||
}, [currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
// Load Watch Progress
|
||||
useEffect(() => {
|
||||
const loadWatchProgress = async () => {
|
||||
if (id && type) {
|
||||
try {
|
||||
const savedProgress = await storageService.getWatchProgress(id, type, episodeId);
|
||||
if (savedProgress) {
|
||||
const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100;
|
||||
|
||||
if (progressPercent < 85) {
|
||||
setResumePosition(savedProgress.currentTime);
|
||||
setSavedDuration(savedProgress.duration);
|
||||
|
||||
if (appSettings.alwaysResume) {
|
||||
setInitialPosition(savedProgress.currentTime);
|
||||
initialSeekTargetRef.current = savedProgress.currentTime;
|
||||
seekToTime(savedProgress.currentTime);
|
||||
} else {
|
||||
setShowResumeOverlay(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[useWatchProgress] Error loading watch progress:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadWatchProgress();
|
||||
}, [id, type, episodeId, appSettings.alwaysResume]);
|
||||
|
||||
const saveWatchProgress = async () => {
|
||||
if (id && type && currentTimeRef.current > 0 && durationRef.current > 0) {
|
||||
const progress = {
|
||||
currentTime: currentTimeRef.current,
|
||||
duration: durationRef.current,
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
try {
|
||||
await storageService.setWatchProgress(id, type, progress, episodeId);
|
||||
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
|
||||
} catch (error) {
|
||||
logger.error('[useWatchProgress] Error saving watch progress:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save Interval
|
||||
useEffect(() => {
|
||||
if (id && type && !paused && duration > 0) {
|
||||
if (progressSaveInterval) clearInterval(progressSaveInterval);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
saveWatchProgress();
|
||||
}, 10000);
|
||||
|
||||
setProgressSaveInterval(interval);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
setProgressSaveInterval(null);
|
||||
};
|
||||
}
|
||||
}, [id, type, paused, currentTime, duration]);
|
||||
|
||||
// Unmount Save
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (id && type && durationRef.current > 0) {
|
||||
saveWatchProgress();
|
||||
traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
|
||||
}
|
||||
};
|
||||
}, [id, type]);
|
||||
|
||||
return {
|
||||
resumePosition,
|
||||
savedDuration,
|
||||
initialPosition,
|
||||
setInitialPosition,
|
||||
showResumeOverlay,
|
||||
setShowResumeOverlay,
|
||||
saveWatchProgress,
|
||||
initialSeekTargetRef
|
||||
};
|
||||
};
|
||||
|
|
@ -110,3 +110,5 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioTrackModal;
|
||||
|
|
|
|||
|
|
@ -374,3 +374,5 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodeStreamsModal;
|
||||
|
|
|
|||
|
|
@ -170,3 +170,5 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodesModal;
|
||||
|
|
|
|||
|
|
@ -276,4 +276,6 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default SourcesModal;
|
||||
|
|
@ -260,32 +260,37 @@ export const styles = StyleSheet.create({
|
|||
},
|
||||
gestureIndicatorContainer: {
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '50%',
|
||||
transform: [{ translateX: -75 }, { translateY: -40 }],
|
||||
width: 150,
|
||||
height: 80,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
borderRadius: 16,
|
||||
flexDirection: 'row',
|
||||
top: 40,
|
||||
alignSelf: 'center',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
},
|
||||
gestureIndicatorPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 24,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
gap: 8,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 10,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
gestureText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 24,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
minWidth: 48,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
bottomControls: {
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@ export interface TextTrack {
|
|||
type?: string | null; // Adjusting type based on linter error
|
||||
}
|
||||
|
||||
// Define the possible resize modes - force to stretch for absolute full screen
|
||||
export type ResizeModeType = 'contain' | 'cover' | 'none';
|
||||
export const resizeModes: ResizeModeType[] = ['cover']; // Force cover mode for absolute full screen
|
||||
// Define the possible resize modes
|
||||
export type ResizeModeType = 'contain' | 'cover' | 'stretch' | 'none';
|
||||
export const resizeModes: ResizeModeType[] = ['cover', 'contain', 'stretch'];
|
||||
|
||||
// Add VLC specific interface for their event structure
|
||||
export interface VlcMediaEvent {
|
||||
|
|
|
|||
Loading…
Reference in a new issue