mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
reafactor android videoplayer
This commit is contained in:
parent
32df7d79ad
commit
a50f8de913
18 changed files with 2047 additions and 3985 deletions
File diff suppressed because it is too large
Load diff
194
src/components/player/android/components/GestureControls.tsx
Normal file
194
src/components/player/android/components/GestureControls.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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 */}
|
||||
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View
|
||||
style={[
|
||||
localStyles.iconWrapper,
|
||||
{
|
||||
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(242, 184, 181)'
|
||||
: 'rgba(59, 59, 59)'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={
|
||||
gestureControls.showVolumeOverlay
|
||||
? getVolumeIcon(volume)
|
||||
: getBrightnessIcon(brightness)
|
||||
}
|
||||
size={24}
|
||||
color={
|
||||
gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(96, 20, 16)'
|
||||
: 'rgba(255, 255, 255)'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
localStyles.gestureText,
|
||||
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' }
|
||||
]}
|
||||
>
|
||||
{gestureControls.showVolumeOverlay && volume === 0
|
||||
? "Muted"
|
||||
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%`
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
228
src/components/player/android/components/PauseOverlay.tsx
Normal file
228
src/components/player/android/components/PauseOverlay.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import React, { useState, useRef } 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';
|
||||
|
||||
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, StyleSheet } 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>
|
||||
);
|
||||
};
|
||||
208
src/components/player/android/components/VideoSurface.tsx
Normal file
208
src/components/player/android/components/VideoSurface.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native';
|
||||
import Video, { ViewType, VideoRef, ResizeMode } from 'react-native-video';
|
||||
import VlcVideoPlayer, { VlcPlayerRef } from '../../VlcVideoPlayer';
|
||||
import { PinchGestureHandler } from 'react-native-gesture-handler';
|
||||
import { styles } from '../../utils/playerStyles';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
import { ResizeModeType, SelectedTrack } from '../../utils/playerTypes';
|
||||
|
||||
const getVideoResizeMode = (resizeMode: ResizeModeType) => {
|
||||
switch (resizeMode) {
|
||||
case 'contain': return 'contain';
|
||||
case 'cover': return 'cover';
|
||||
case 'none': return 'contain';
|
||||
default: return 'contain';
|
||||
}
|
||||
};
|
||||
|
||||
interface VideoSurfaceProps {
|
||||
useVLC: boolean;
|
||||
forceVlcRemount: boolean;
|
||||
processedStreamUrl: string;
|
||||
volume: number;
|
||||
playbackSpeed: number;
|
||||
zoomScale: number;
|
||||
resizeMode: ResizeModeType;
|
||||
paused: boolean;
|
||||
currentStreamUrl: string;
|
||||
headers: any;
|
||||
videoType: any;
|
||||
vlcSelectedAudioTrack?: number;
|
||||
vlcSelectedSubtitleTrack?: number;
|
||||
vlcRestoreTime?: number;
|
||||
vlcKey: string;
|
||||
selectedAudioTrack: any;
|
||||
selectedTextTrack: any;
|
||||
useCustomSubtitles: boolean;
|
||||
|
||||
// Callbacks
|
||||
toggleControls: () => void;
|
||||
onLoad: (data: any) => void;
|
||||
onProgress: (data: any) => void;
|
||||
onSeek: (data: any) => void;
|
||||
onEnd: () => void;
|
||||
onError: (err: any) => void;
|
||||
onBuffer: (buf: any) => void;
|
||||
onTracksUpdate: (tracks: any) => void;
|
||||
|
||||
// Refs
|
||||
vlcPlayerRef: React.RefObject<VlcPlayerRef>;
|
||||
videoRef: React.RefObject<VideoRef>;
|
||||
pinchRef: any;
|
||||
|
||||
// Handlers
|
||||
onPinchGestureEvent: any;
|
||||
onPinchHandlerStateChange: any;
|
||||
vlcLoadedRef: React.MutableRefObject<boolean>;
|
||||
screenDimensions: { width: number, height: number };
|
||||
customVideoStyles: any;
|
||||
|
||||
// Debugging
|
||||
loadStartAtRef: React.MutableRefObject<number | null>;
|
||||
firstFrameAtRef: React.MutableRefObject<number | null>;
|
||||
}
|
||||
|
||||
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
||||
useVLC,
|
||||
forceVlcRemount,
|
||||
processedStreamUrl,
|
||||
volume,
|
||||
playbackSpeed,
|
||||
zoomScale,
|
||||
resizeMode,
|
||||
paused,
|
||||
currentStreamUrl,
|
||||
headers,
|
||||
videoType,
|
||||
vlcSelectedAudioTrack,
|
||||
vlcSelectedSubtitleTrack,
|
||||
vlcRestoreTime,
|
||||
vlcKey,
|
||||
selectedAudioTrack,
|
||||
selectedTextTrack,
|
||||
useCustomSubtitles,
|
||||
toggleControls,
|
||||
onLoad,
|
||||
onProgress,
|
||||
onSeek,
|
||||
onEnd,
|
||||
onError,
|
||||
onBuffer,
|
||||
onTracksUpdate,
|
||||
vlcPlayerRef,
|
||||
videoRef,
|
||||
pinchRef,
|
||||
onPinchGestureEvent,
|
||||
onPinchHandlerStateChange,
|
||||
vlcLoadedRef,
|
||||
screenDimensions,
|
||||
customVideoStyles,
|
||||
loadStartAtRef,
|
||||
firstFrameAtRef
|
||||
}) => {
|
||||
|
||||
const isHlsStream = (url: string) => {
|
||||
return url.includes('.m3u8') || url.includes('m3u8') ||
|
||||
url.includes('hls') || url.includes('playlist') ||
|
||||
(videoType && videoType.toLowerCase() === 'm3u8');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.videoContainer, {
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
}]}>
|
||||
<PinchGestureHandler
|
||||
ref={pinchRef}
|
||||
onGestureEvent={onPinchGestureEvent}
|
||||
onHandlerStateChange={onPinchHandlerStateChange}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
style={{ flex: 1 }}
|
||||
activeOpacity={1}
|
||||
onPress={toggleControls}
|
||||
>
|
||||
{useVLC && !forceVlcRemount ? (
|
||||
<VlcVideoPlayer
|
||||
ref={vlcPlayerRef}
|
||||
source={processedStreamUrl}
|
||||
volume={volume}
|
||||
playbackSpeed={playbackSpeed}
|
||||
zoomScale={zoomScale}
|
||||
resizeMode={resizeMode}
|
||||
onLoad={(data) => {
|
||||
vlcLoadedRef.current = true;
|
||||
onLoad(data);
|
||||
if (!paused && vlcPlayerRef.current) {
|
||||
setTimeout(() => {
|
||||
if (vlcPlayerRef.current) {
|
||||
vlcPlayerRef.current.play();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onProgress={onProgress}
|
||||
onSeek={onSeek}
|
||||
onEnd={onEnd}
|
||||
onError={onError}
|
||||
onTracksUpdate={onTracksUpdate}
|
||||
selectedAudioTrack={vlcSelectedAudioTrack}
|
||||
selectedSubtitleTrack={vlcSelectedSubtitleTrack}
|
||||
restoreTime={vlcRestoreTime}
|
||||
forceRemount={forceVlcRemount}
|
||||
key={vlcKey}
|
||||
/>
|
||||
) : (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
style={[styles.video, customVideoStyles]}
|
||||
source={{
|
||||
uri: currentStreamUrl,
|
||||
headers: headers,
|
||||
type: isHlsStream(currentStreamUrl) ? 'm3u8' : videoType
|
||||
}}
|
||||
paused={paused}
|
||||
onLoadStart={() => {
|
||||
loadStartAtRef.current = Date.now();
|
||||
}}
|
||||
onProgress={onProgress}
|
||||
onLoad={onLoad}
|
||||
onReadyForDisplay={() => {
|
||||
firstFrameAtRef.current = Date.now();
|
||||
}}
|
||||
onSeek={onSeek}
|
||||
onEnd={onEnd}
|
||||
onError={onError}
|
||||
onBuffer={onBuffer}
|
||||
resizeMode={getVideoResizeMode(resizeMode)}
|
||||
selectedAudioTrack={selectedAudioTrack || undefined}
|
||||
selectedTextTrack={useCustomSubtitles ? { type: 'disabled' } as any : (selectedTextTrack >= 0 ? { type: 'index', value: selectedTextTrack } as any : undefined)}
|
||||
rate={playbackSpeed}
|
||||
volume={volume}
|
||||
muted={false}
|
||||
repeat={false}
|
||||
playInBackground={false}
|
||||
playWhenInactive={false}
|
||||
ignoreSilentSwitch="ignore"
|
||||
mixWithOthers="inherit"
|
||||
progressUpdateInterval={500}
|
||||
disableFocus={true}
|
||||
allowsExternalPlayback={false}
|
||||
preventsDisplaySleepDuringVideoPlayback={true}
|
||||
viewType={Platform.OS === 'android' ? ViewType.SURFACE : undefined}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</PinchGestureHandler>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
59
src/components/player/android/hooks/useNextEpisode.ts
Normal file
59
src/components/player/android/hooks/useNextEpisode.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useMemo } from 'react';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
|
||||
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 as any) !== '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 as any) !== '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/android/hooks/useOpeningAnimation.ts
Normal file
149
src/components/player/android/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('[AndroidVideoPlayer] Opening animation fallback triggered');
|
||||
setIsOpeningAnimationComplete(true);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return {
|
||||
fadeAnim,
|
||||
openingFadeAnim,
|
||||
openingScaleAnim,
|
||||
backgroundFadeAnim,
|
||||
backdropImageOpacityAnim,
|
||||
logoScaleAnim,
|
||||
logoOpacityAnim,
|
||||
pulseAnim,
|
||||
isOpeningAnimationComplete,
|
||||
shouldHideOpeningOverlay,
|
||||
isBackdropLoaded,
|
||||
startOpeningAnimation,
|
||||
completeOpeningAnimation
|
||||
};
|
||||
};
|
||||
71
src/components/player/android/hooks/usePlayerControls.ts
Normal file
71
src/components/player/android/hooks/usePlayerControls.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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 = (
|
||||
videoRef: any,
|
||||
vlcPlayerRef: any,
|
||||
useVLC: boolean,
|
||||
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 (useVLC) {
|
||||
if (vlcPlayerRef.current && duration > 0) {
|
||||
if (DEBUG_MODE) logger.log(`[usePlayerControls][VLC] Seeking to ${timeInSeconds}`);
|
||||
vlcPlayerRef.current.seek(timeInSeconds);
|
||||
}
|
||||
} else {
|
||||
if (videoRef.current && duration > 0 && !isSeeking.current) {
|
||||
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
|
||||
|
||||
isSeeking.current = true;
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
iosWasPausedDuringSeekRef.current = paused;
|
||||
if (!paused) setPaused(true);
|
||||
}
|
||||
|
||||
// Actually perform the seek
|
||||
videoRef.current.seek(timeInSeconds);
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMounted.current && isSeeking.current) {
|
||||
isSeeking.current = false;
|
||||
if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) {
|
||||
setPaused(false);
|
||||
iosWasPausedDuringSeekRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [useVLC, duration, paused, setPaused, videoRef, vlcPlayerRef, isSeeking, isMounted]);
|
||||
|
||||
const skip = useCallback((seconds: number) => {
|
||||
seekToTime(currentTime + seconds);
|
||||
}, [currentTime, seekToTime]);
|
||||
|
||||
return {
|
||||
togglePlayback,
|
||||
seekToTime,
|
||||
skip,
|
||||
iosWasPausedDuringSeekRef
|
||||
};
|
||||
};
|
||||
28
src/components/player/android/hooks/usePlayerModals.ts
Normal file
28
src/components/player/android/hooks/usePlayerModals.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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);
|
||||
|
||||
// Some modals have associated data
|
||||
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
|
||||
const [errorDetails, setErrorDetails] = useState<string>('');
|
||||
|
||||
return {
|
||||
showAudioModal, setShowAudioModal,
|
||||
showSubtitleModal, setShowSubtitleModal,
|
||||
showSpeedModal, setShowSpeedModal,
|
||||
showSourcesModal, setShowSourcesModal,
|
||||
showEpisodesModal, setShowEpisodesModal,
|
||||
showEpisodeStreamsModal, setShowEpisodeStreamsModal,
|
||||
showErrorModal, setShowErrorModal,
|
||||
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
|
||||
errorDetails, setErrorDetails
|
||||
};
|
||||
};
|
||||
107
src/components/player/android/hooks/usePlayerSetup.ts
Normal file
107
src/components/player/android/hooks/usePlayerSetup.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { StatusBar, Platform, Dimensions, AppState } from 'react-native';
|
||||
import RNImmersiveMode from 'react-native-immersive-mode';
|
||||
import * as Brightness from 'expo-brightness';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const DEBUG_MODE = false;
|
||||
|
||||
export const usePlayerSetup = (
|
||||
setScreenDimensions: (dim: any) => void,
|
||||
setVolume: (vol: number) => void,
|
||||
setBrightness: (bri: number) => void,
|
||||
paused: boolean
|
||||
) => {
|
||||
const originalSystemBrightnessRef = useRef<number | null>(null);
|
||||
const originalSystemBrightnessModeRef = useRef<number | null>(null);
|
||||
const isAppBackgrounded = useRef(false);
|
||||
|
||||
const enableImmersiveMode = () => {
|
||||
if (Platform.OS === 'android') {
|
||||
RNImmersiveMode.setBarTranslucent(true);
|
||||
RNImmersiveMode.fullLayout(true);
|
||||
StatusBar.setHidden(true, 'none');
|
||||
}
|
||||
};
|
||||
|
||||
const disableImmersiveMode = () => {
|
||||
if (Platform.OS === 'android') {
|
||||
RNImmersiveMode.setBarTranslucent(false);
|
||||
RNImmersiveMode.fullLayout(false);
|
||||
StatusBar.setHidden(false, 'fade');
|
||||
}
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
enableImmersiveMode();
|
||||
return () => { };
|
||||
}, [])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial Setup
|
||||
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
|
||||
setScreenDimensions(screen);
|
||||
enableImmersiveMode();
|
||||
});
|
||||
|
||||
StatusBar.setHidden(true, 'none');
|
||||
enableImmersiveMode();
|
||||
|
||||
// Initialize volume (default to 1.0)
|
||||
setVolume(1.0);
|
||||
|
||||
// Initialize Brightness
|
||||
const initBrightness = async () => {
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const [sysBright, sysMode] = await Promise.all([
|
||||
(Brightness as any).getSystemBrightnessAsync?.(),
|
||||
(Brightness as any).getSystemBrightnessModeAsync?.()
|
||||
]);
|
||||
originalSystemBrightnessRef.current = typeof sysBright === 'number' ? sysBright : null;
|
||||
originalSystemBrightnessModeRef.current = typeof sysMode === 'number' ? sysMode : null;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const currentBrightness = await Brightness.getBrightnessAsync();
|
||||
setBrightness(currentBrightness);
|
||||
} catch (error) {
|
||||
logger.warn('[usePlayerSetup] Error setting brightness', error);
|
||||
setBrightness(1.0);
|
||||
}
|
||||
};
|
||||
initBrightness();
|
||||
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
disableImmersiveMode();
|
||||
|
||||
// Restore brightness on unmount
|
||||
if (Platform.OS === 'android' && originalSystemBrightnessRef.current !== null) {
|
||||
// restoration logic normally happens here or in a separate effect
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle App State
|
||||
useEffect(() => {
|
||||
const onAppStateChange = (state: string) => {
|
||||
if (state === 'active') {
|
||||
isAppBackgrounded.current = false;
|
||||
enableImmersiveMode();
|
||||
} else if (state === 'background' || state === 'inactive') {
|
||||
isAppBackgrounded.current = true;
|
||||
}
|
||||
};
|
||||
const sub = AppState.addEventListener('change', onAppStateChange);
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
return { isAppBackgrounded };
|
||||
};
|
||||
41
src/components/player/android/hooks/usePlayerState.ts
Normal file
41
src/components/player/android/hooks/usePlayerState.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { Dimensions } from 'react-native';
|
||||
import { ResizeModeType, SelectedTrack } from '../../utils/playerTypes';
|
||||
|
||||
export const usePlayerState = () => {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
|
||||
// UI State
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain');
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
|
||||
// Layout State
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
|
||||
const [screenDimensions, setScreenDimensions] = useState(Dimensions.get('screen'));
|
||||
|
||||
// Logic State
|
||||
const isSeeking = useRef(false);
|
||||
const isDragging = useRef(false);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
return {
|
||||
paused, setPaused,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
buffered, setBuffered,
|
||||
showControls, setShowControls,
|
||||
resizeMode, setResizeMode,
|
||||
isBuffering, setIsBuffering,
|
||||
isVideoLoaded, setIsVideoLoaded,
|
||||
videoAspectRatio, setVideoAspectRatio,
|
||||
screenDimensions, setScreenDimensions,
|
||||
isSeeking,
|
||||
isDragging,
|
||||
isMounted,
|
||||
};
|
||||
};
|
||||
61
src/components/player/android/hooks/usePlayerTracks.ts
Normal file
61
src/components/player/android/hooks/usePlayerTracks.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { SelectedTrack, TextTrack, AudioTrack } from '../../utils/playerTypes';
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
name: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const usePlayerTracks = (
|
||||
useVLC: boolean,
|
||||
vlcAudioTracks: Track[],
|
||||
vlcSubtitleTracks: Track[],
|
||||
vlcSelectedAudioTrack: number | undefined,
|
||||
vlcSelectedSubtitleTrack: number | undefined
|
||||
) => {
|
||||
// React Native Video Tracks
|
||||
const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState<Track[]>([]);
|
||||
const [rnVideoTextTracks, setRnVideoTextTracks] = useState<Track[]>([]);
|
||||
|
||||
// Selected Tracks State
|
||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<SelectedTrack | null>({ type: 'system' });
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
|
||||
|
||||
// Unified Tracks
|
||||
const ksAudioTracks = useMemo(() =>
|
||||
useVLC ? vlcAudioTracks : rnVideoAudioTracks,
|
||||
[useVLC, vlcAudioTracks, rnVideoAudioTracks]
|
||||
);
|
||||
|
||||
const ksTextTracks = useMemo(() =>
|
||||
useVLC ? vlcSubtitleTracks : rnVideoTextTracks,
|
||||
[useVLC, vlcSubtitleTracks, rnVideoTextTracks]
|
||||
);
|
||||
|
||||
// Unified Selection
|
||||
const computedSelectedAudioTrack = useMemo(() =>
|
||||
useVLC
|
||||
? (vlcSelectedAudioTrack ?? null)
|
||||
: (selectedAudioTrack?.type === 'index' && selectedAudioTrack?.value !== undefined
|
||||
? Number(selectedAudioTrack?.value)
|
||||
: null),
|
||||
[useVLC, vlcSelectedAudioTrack, selectedAudioTrack]
|
||||
);
|
||||
|
||||
const computedSelectedTextTrack = useMemo(() =>
|
||||
useVLC ? (vlcSelectedSubtitleTrack ?? -1) : selectedTextTrack,
|
||||
[useVLC, vlcSelectedSubtitleTrack, selectedTextTrack]
|
||||
);
|
||||
|
||||
return {
|
||||
rnVideoAudioTracks, setRnVideoAudioTracks,
|
||||
rnVideoTextTracks, setRnVideoTextTracks,
|
||||
selectedAudioTrack, setSelectedAudioTrack,
|
||||
selectedTextTrack, setSelectedTextTrack,
|
||||
ksAudioTracks,
|
||||
ksTextTracks,
|
||||
computedSelectedAudioTrack,
|
||||
computedSelectedTextTrack
|
||||
};
|
||||
};
|
||||
93
src/components/player/android/hooks/useSpeedControl.ts
Normal file
93
src/components/player/android/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
|
||||
};
|
||||
};
|
||||
148
src/components/player/android/hooks/useVlcPlayer.ts
Normal file
148
src/components/player/android/hooks/useVlcPlayer.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
import { VlcPlayerRef } from '../../VlcVideoPlayer';
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
name: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
const DEBUG_MODE = false;
|
||||
|
||||
export const useVlcPlayer = (useVLC: boolean, paused: boolean, currentTime: number) => {
|
||||
const [vlcAudioTracks, setVlcAudioTracks] = useState<Track[]>([]);
|
||||
const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState<Track[]>([]);
|
||||
const [vlcSelectedAudioTrack, setVlcSelectedAudioTrack] = useState<number | undefined>(undefined);
|
||||
const [vlcSelectedSubtitleTrack, setVlcSelectedSubtitleTrack] = useState<number | undefined>(undefined);
|
||||
const [vlcRestoreTime, setVlcRestoreTime] = useState<number | undefined>(undefined);
|
||||
const [forceVlcRemount, setForceVlcRemount] = useState(false);
|
||||
const [vlcKey, setVlcKey] = useState('vlc-initial');
|
||||
|
||||
const vlcPlayerRef = useRef<VlcPlayerRef>(null);
|
||||
const vlcLoadedRef = useRef<boolean>(false);
|
||||
const trackUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Handle VLC pause/play interactions
|
||||
useEffect(() => {
|
||||
if (useVLC && vlcLoadedRef.current && vlcPlayerRef.current) {
|
||||
if (paused) {
|
||||
vlcPlayerRef.current.pause();
|
||||
} else {
|
||||
vlcPlayerRef.current.play();
|
||||
}
|
||||
}
|
||||
}, [useVLC, paused]);
|
||||
|
||||
// Reset forceVlcRemount when VLC becomes inactive
|
||||
useEffect(() => {
|
||||
if (!useVLC && forceVlcRemount) {
|
||||
setForceVlcRemount(false);
|
||||
}
|
||||
}, [useVLC, forceVlcRemount]);
|
||||
|
||||
// Track selection
|
||||
const selectVlcAudioTrack = useCallback((trackId: number | null) => {
|
||||
setVlcSelectedAudioTrack(trackId ?? undefined);
|
||||
logger.log('[AndroidVideoPlayer][VLC] Audio track selected:', trackId);
|
||||
}, []);
|
||||
|
||||
const selectVlcSubtitleTrack = useCallback((trackId: number | null) => {
|
||||
setVlcSelectedSubtitleTrack(trackId ?? undefined);
|
||||
logger.log('[AndroidVideoPlayer][VLC] Subtitle track selected:', trackId);
|
||||
}, []);
|
||||
|
||||
// Track updates handler
|
||||
const handleVlcTracksUpdate = useCallback((tracks: { audio: any[], subtitle: any[] }) => {
|
||||
if (!tracks) return;
|
||||
|
||||
if (trackUpdateTimeoutRef.current) {
|
||||
clearTimeout(trackUpdateTimeoutRef.current);
|
||||
}
|
||||
|
||||
trackUpdateTimeoutRef.current = setTimeout(() => {
|
||||
const { audio = [], subtitle = [] } = tracks;
|
||||
let hasUpdates = false;
|
||||
|
||||
// Process Audio
|
||||
if (Array.isArray(audio) && audio.length > 0) {
|
||||
const formattedAudio = audio.map(track => ({
|
||||
id: track.id,
|
||||
name: track.name || `Track ${track.id + 1}`,
|
||||
language: track.language
|
||||
}));
|
||||
|
||||
const audioChanged = formattedAudio.length !== vlcAudioTracks.length ||
|
||||
formattedAudio.some((track, index) => {
|
||||
const existing = vlcAudioTracks[index];
|
||||
return !existing || track.id !== existing.id || track.name !== existing.name;
|
||||
});
|
||||
|
||||
if (audioChanged) {
|
||||
setVlcAudioTracks(formattedAudio);
|
||||
hasUpdates = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process Subtitles
|
||||
if (Array.isArray(subtitle) && subtitle.length > 0) {
|
||||
const formattedSubs = subtitle.map(track => ({
|
||||
id: track.id,
|
||||
name: track.name || `Track ${track.id + 1}`,
|
||||
language: track.language
|
||||
}));
|
||||
|
||||
const subsChanged = formattedSubs.length !== vlcSubtitleTracks.length ||
|
||||
formattedSubs.some((track, index) => {
|
||||
const existing = vlcSubtitleTracks[index];
|
||||
return !existing || track.id !== existing.id || track.name !== existing.name;
|
||||
});
|
||||
|
||||
if (subsChanged) {
|
||||
setVlcSubtitleTracks(formattedSubs);
|
||||
hasUpdates = true;
|
||||
}
|
||||
}
|
||||
|
||||
trackUpdateTimeoutRef.current = null;
|
||||
}, 100);
|
||||
}, [vlcAudioTracks, vlcSubtitleTracks]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (trackUpdateTimeoutRef.current) {
|
||||
clearTimeout(trackUpdateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const remountVlc = useCallback((reason: string) => {
|
||||
if (useVLC) {
|
||||
logger.log(`[VLC] Forcing complete remount: ${reason}`);
|
||||
setVlcRestoreTime(currentTime);
|
||||
setForceVlcRemount(true);
|
||||
vlcLoadedRef.current = false;
|
||||
setTimeout(() => {
|
||||
setForceVlcRemount(false);
|
||||
setVlcKey(`vlc-${reason}-${Date.now()}`);
|
||||
}, 100);
|
||||
}
|
||||
}, [useVLC, currentTime]);
|
||||
|
||||
return {
|
||||
vlcAudioTracks,
|
||||
vlcSubtitleTracks,
|
||||
vlcSelectedAudioTrack,
|
||||
vlcSelectedSubtitleTrack,
|
||||
selectVlcAudioTrack,
|
||||
selectVlcSubtitleTrack,
|
||||
vlcPlayerRef,
|
||||
vlcLoadedRef,
|
||||
forceVlcRemount,
|
||||
vlcRestoreTime,
|
||||
vlcKey,
|
||||
handleVlcTracksUpdate,
|
||||
remountVlc,
|
||||
};
|
||||
};
|
||||
120
src/components/player/android/hooks/useWatchProgress.ts
Normal file
120
src/components/player/android/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
|
||||
};
|
||||
};
|
||||
|
|
@ -140,8 +140,8 @@ export const styles = StyleSheet.create({
|
|||
topButton: {
|
||||
padding: 8,
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
/* CloudStream Style - Center Controls */
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
|
|
@ -156,7 +156,7 @@ export const styles = StyleSheet.create({
|
|||
gap: controlsGap,
|
||||
zIndex: 1000,
|
||||
},
|
||||
|
||||
|
||||
/* CloudStream Style - Seek Buttons */
|
||||
seekButtonContainer: {
|
||||
alignItems: 'center',
|
||||
|
|
@ -187,7 +187,7 @@ export const styles = StyleSheet.create({
|
|||
textAlign: 'center',
|
||||
marginLeft: -7,
|
||||
},
|
||||
|
||||
|
||||
/* CloudStream Style - Play Button */
|
||||
playButton: {
|
||||
alignItems: 'center',
|
||||
|
|
@ -202,7 +202,7 @@ export const styles = StyleSheet.create({
|
|||
color: '#FFFFFF',
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
|
||||
/* CloudStream Style - Arc Animations */
|
||||
arcContainer: {
|
||||
position: 'absolute',
|
||||
|
|
@ -233,9 +233,60 @@ export const styles = StyleSheet.create({
|
|||
position: 'absolute',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
|
||||
|
||||
|
||||
speedActivatedOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1500,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
speedActivatedContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 30,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
speedActivatedText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
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',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 10,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
gestureText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
bottomControls: {
|
||||
gap: 12,
|
||||
|
|
@ -1162,4 +1213,4 @@ export const styles = StyleSheet.create({
|
|||
fontSize: skipTextFont,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -71,8 +71,8 @@ export interface VlcMediaEvent {
|
|||
duration: number;
|
||||
bufferTime?: number;
|
||||
isBuffering?: boolean;
|
||||
audioTracks?: Array<{id: number, name: string, language?: string}>;
|
||||
textTracks?: Array<{id: number, name: string, language?: string}>;
|
||||
audioTracks?: Array<{ id: number, name: string, language?: string }>;
|
||||
textTracks?: Array<{ id: number, name: string, language?: string }>;
|
||||
selectedAudioTrack?: number;
|
||||
selectedTextTrack?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,4 +200,35 @@ export const detectRTL = (text: string): boolean => {
|
|||
// Consider RTL if at least 30% of non-whitespace characters are RTL
|
||||
// This handles mixed-language subtitles (e.g., Arabic with English numbers)
|
||||
return rtlCount / nonWhitespace.length >= 0.3;
|
||||
};
|
||||
|
||||
// Check if a URL is an HLS stream
|
||||
export const isHlsStream = (url: string | undefined): boolean => {
|
||||
if (!url) return false;
|
||||
return url.includes('.m3u8') || url.includes('.m3u');
|
||||
};
|
||||
|
||||
// Process URL for VLC to handle specific protocol requirements
|
||||
export const processUrlForVLC = (url: string | undefined): string => {
|
||||
if (!url) return '';
|
||||
// Some HLS streams need to be passed with specific protocols for VLC
|
||||
if (url.startsWith('https://') && isHlsStream(url)) {
|
||||
// Standard HTTPS is usually fine, but some implementations might prefer http
|
||||
return url;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
// Default headers for Android requests
|
||||
export const defaultAndroidHeaders = {
|
||||
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; Mobile; rv:89.0) Gecko/89.0 Firefox/89.0',
|
||||
'Accept': '*/*'
|
||||
};
|
||||
|
||||
// Get specific headers for HLS streams
|
||||
export const getHlsHeaders = () => {
|
||||
return {
|
||||
...defaultAndroidHeaders,
|
||||
'Accept': 'application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain',
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue