reafactor android videoplayer

This commit is contained in:
tapframe 2025-12-22 11:35:25 +05:30
parent 32df7d79ad
commit a50f8de913
18 changed files with 2047 additions and 3985 deletions

File diff suppressed because it is too large Load diff

View 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>
)}
</>
);
};

View 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>
);
};

View file

@ -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>
);
};

View 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>
);
};

View 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 };
};

View 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
};
};

View 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
};
};

View 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
};
};

View 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 };
};

View 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,
};
};

View 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
};
};

View 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
};
};

View 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,
};
};

View 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
};
};

View file

@ -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,
},
});
});

View file

@ -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;
}

View file

@ -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',
};
};