refactor ksplayer

This commit is contained in:
tapframe 2025-12-22 13:58:16 +05:30
parent a50f8de913
commit 0d6d69e0a8
38 changed files with 3396 additions and 3332 deletions

View file

@ -4,26 +4,29 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
// Hooks
import { usePlayerState } from './android/hooks/usePlayerState';
// Shared Hooks (cross-platform)
import {
usePlayerState,
usePlayerModals,
useSpeedControl,
useOpeningAnimation
} from './hooks';
// Android-specific hooks (VLC integration, dual player support)
import { usePlayerSetup } from './android/hooks/usePlayerSetup';
import { useVlcPlayer } from './android/hooks/useVlcPlayer';
import { usePlayerTracks } from './android/hooks/usePlayerTracks';
import { useWatchProgress } from './android/hooks/useWatchProgress';
import { usePlayerControls } from './android/hooks/usePlayerControls';
import { useSpeedControl } from './android/hooks/useSpeedControl';
import { useNextEpisode } from './android/hooks/useNextEpisode';
import { useOpeningAnimation } from './android/hooks/useOpeningAnimation';
import { usePlayerModals } from './android/hooks/usePlayerModals';
// App-level Hooks
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import { useMetadata } from '../../hooks/useMetadata';
import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls';
// Components
import { VideoSurface } from './android/components/VideoSurface';
import { GestureControls } from './android/components/GestureControls';
import { PauseOverlay } from './android/components/PauseOverlay';
import { SpeedActivatedOverlay } from './android/components/SpeedActivatedOverlay';
// Shared Components
import { GestureControls, PauseOverlay, SpeedActivatedOverlay } from './components';
import LoadingOverlay from './modals/LoadingOverlay';
import PlayerControls from './controls/PlayerControls';
import { AudioTrackModal } from './modals/AudioTrackModal';
@ -33,12 +36,14 @@ import { SourcesModal } from './modals/SourcesModal';
import { EpisodesModal } from './modals/EpisodesModal';
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
// Android-specific components
import { VideoSurface } from './android/components/VideoSurface';
// Utils
import { logger } from '../../utils/logger';
import { styles } from './utils/playerStyles';
import { formatTime, isHlsStream, processUrlForVLC, getHlsHeaders, defaultAndroidHeaders } from './utils/playerUtils';
import { storageService } from '../../services/storageService';
// SelectedTrackType removed - using string literals instead
const DEBUG_MODE = false;
@ -543,6 +548,7 @@ const AndroidVideoPlayer: React.FC = () => {
onClose={() => modals.setShowEpisodeStreamsModal(false)}
episode={modals.selectedEpisodeForStreams}
onSelectStream={handleEpisodeStreamSelect}
metadata={{ id: id, name: title }}
/>
</View>

File diff suppressed because it is too large Load diff

View file

@ -11,11 +11,23 @@ const getVideoResizeMode = (resizeMode: ResizeModeType) => {
switch (resizeMode) {
case 'contain': return 'contain';
case 'cover': return 'cover';
case 'stretch': return 'contain';
case 'none': return 'contain';
default: return 'contain';
}
};
// VLC only supports 'contain' | 'cover' | 'none'
const getVlcResizeMode = (resizeMode: ResizeModeType): 'contain' | 'cover' | 'none' => {
switch (resizeMode) {
case 'contain': return 'contain';
case 'cover': return 'cover';
case 'stretch': return 'cover'; // stretch is not supported, use cover
case 'none': return 'none';
default: return 'contain';
}
};
interface VideoSurfaceProps {
useVLC: boolean;
forceVlcRemount: boolean;
@ -137,7 +149,7 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
volume={volume}
playbackSpeed={playbackSpeed}
zoomScale={zoomScale}
resizeMode={resizeMode}
resizeMode={getVlcResizeMode(resizeMode)}
onLoad={(data) => {
vlcLoadedRef.current = true;
onLoad(data);

View file

@ -0,0 +1,199 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
TapGestureHandler,
PanGestureHandler,
LongPressGestureHandler,
State
} from 'react-native-gesture-handler';
import { MaterialIcons } from '@expo/vector-icons';
import { styles as localStyles } from '../utils/playerStyles';
interface GestureControlsProps {
screenDimensions: { width: number, height: number };
gestureControls: any;
onLongPressActivated: () => void;
onLongPressEnd: () => void;
onLongPressStateChange: (event: any) => void;
toggleControls: () => void;
showControls: boolean;
hideControls: () => void;
volume: number;
brightness: number;
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
}
export const GestureControls: React.FC<GestureControlsProps> = ({
screenDimensions,
gestureControls,
onLongPressActivated,
onLongPressEnd,
onLongPressStateChange,
toggleControls,
showControls,
hideControls,
volume,
brightness,
controlsTimeout
}) => {
const getVolumeIcon = (value: number) => {
if (value === 0) return 'volume-off';
if (value < 0.3) return 'volume-mute';
if (value < 0.6) return 'volume-down';
return 'volume-up';
};
const getBrightnessIcon = (value: number) => {
if (value < 0.3) return 'brightness-low';
if (value < 0.7) return 'brightness-medium';
return 'brightness-high';
};
return (
<>
{/* Left side gesture handler - brightness + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onBrightnessGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Right side gesture handler - volume + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onVolumeGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
right: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Center area tap handler */}
<TapGestureHandler
onActivated={() => {
if (showControls) {
const timeoutId = setTimeout(() => {
hideControls();
}, 0);
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
}
controlsTimeout.current = timeoutId;
} else {
toggleControls();
}
}}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: screenDimensions.width * 0.4,
width: screenDimensions.width * 0.2,
height: screenDimensions.height * 0.7,
zIndex: 5,
}} />
</TapGestureHandler>
{/* Volume/Brightness Pill Overlay - Compact top design */}
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
<View style={localStyles.gestureIndicatorContainer}>
<View style={[
localStyles.gestureIndicatorPill,
gestureControls.showVolumeOverlay && volume === 0 && {
backgroundColor: 'rgba(96, 20, 16, 0.85)'
}
]}>
<View
style={[
localStyles.iconWrapper,
gestureControls.showVolumeOverlay && volume === 0 && {
backgroundColor: 'rgba(242, 184, 181, 0.3)'
}
]}
>
<MaterialIcons
name={
gestureControls.showVolumeOverlay
? getVolumeIcon(volume)
: getBrightnessIcon(brightness)
}
size={18}
color={
gestureControls.showVolumeOverlay && volume === 0
? 'rgba(242, 184, 181, 1)'
: 'rgba(255, 255, 255, 0.9)'
}
/>
</View>
<Text
style={[
localStyles.gestureText,
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181, 1)' }
]}
>
{gestureControls.showVolumeOverlay && volume === 0
? "Muted"
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%`
}
</Text>
</View>
</View>
)}
</>
);
};

View file

@ -0,0 +1,259 @@
import React, { useState, useRef, useEffect } from 'react';
import { View, Text, TouchableOpacity, ScrollView, Animated, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// Delay before showing pause overlay (in milliseconds)
const PAUSE_OVERLAY_DELAY = 5000;
interface PauseOverlayProps {
visible: boolean;
onClose: () => void;
title: string;
episodeTitle?: string;
season?: number;
episode?: number;
year?: string | number;
type: string;
description: string;
cast: any[];
screenDimensions: { width: number, height: number };
}
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
visible,
onClose,
title,
episodeTitle,
season,
episode,
year,
type,
description,
cast,
screenDimensions
}) => {
const insets = useSafeAreaInsets();
// Internal state to track if overlay should actually be shown (after delay)
const [shouldShow, setShouldShow] = useState(false);
const delayTimerRef = useRef<NodeJS.Timeout | null>(null);
// Handle delay logic - show overlay only after paused for 5 seconds
useEffect(() => {
if (visible) {
// Start timer to show overlay after delay
delayTimerRef.current = setTimeout(() => {
setShouldShow(true);
}, PAUSE_OVERLAY_DELAY);
} else {
// Immediately hide when not paused
if (delayTimerRef.current) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
setShouldShow(false);
}
return () => {
if (delayTimerRef.current) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
};
}, [visible]);
// Internal Animation State
const pauseOverlayOpacity = useRef(new Animated.Value(shouldShow ? 1 : 0)).current;
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
const metadataOpacity = useRef(new Animated.Value(1)).current;
const metadataScale = useRef(new Animated.Value(1)).current;
// Cast Details State
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [showCastDetails, setShowCastDetails] = useState(false);
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
useEffect(() => {
Animated.timing(pauseOverlayOpacity, {
toValue: shouldShow ? 1 : 0,
duration: 250,
useNativeDriver: true
}).start();
}, [shouldShow]);
if (!shouldShow && !showCastDetails) return null;
return (
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: pauseOverlayOpacity,
}}
>
{/* Horizontal Fade */}
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
<LinearGradient
colors={[
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.0)'
]}
locations={[0, 0.3, 0.6, 1]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={{
position: 'absolute',
left: 24 + insets.left,
right: 24 + insets.right,
top: 24 + insets.top,
bottom: 110 + insets.bottom,
transform: [{ translateY: pauseOverlayTranslateY }]
}}>
{showCastDetails && selectedCastMember ? (
<Animated.View
style={{
flex: 1,
justifyContent: 'center',
opacity: castDetailsOpacity,
transform: [{ scale: castDetailsScale }]
}}
>
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
<TouchableOpacity
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
onPress={() => {
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(false);
setSelectedCastMember(null);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
{selectedCastMember.profile_path && (
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
)}
<View style={{ flex: 1, paddingTop: 8 }}>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
{selectedCastMember.name}
</Text>
{selectedCastMember.character && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
as {selectedCastMember.character}
</Text>
)}
{selectedCastMember.biography && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
{selectedCastMember.biography}
</Text>
)}
</View>
</View>
</View>
</Animated.View>
) : (
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
<View>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
{title}
</Text>
{!!year && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
</Text>
)}
{!!episodeTitle && (
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
{episodeTitle}
</Text>
)}
{description && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
{description}
</Text>
)}
{cast && cast.length > 0 && (
<View style={{ marginTop: 16 }}>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{cast.slice(0, 6).map((castMember: any, index: number) => (
<TouchableOpacity
key={castMember.id || index}
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
onPress={() => {
setSelectedCastMember(castMember);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(true);
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
{castMember.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
</Animated.View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
};

View file

@ -0,0 +1,38 @@
/**
* Shared Speed Activated Overlay Component
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import React from 'react';
import { View, Text, Animated } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { styles } from '../utils/playerStyles';
interface SpeedActivatedOverlayProps {
visible: boolean;
opacity: Animated.Value;
speed: number;
}
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
visible,
opacity,
speed
}) => {
if (!visible) return null;
return (
<Animated.View
style={[
styles.speedActivatedOverlay,
{ opacity: opacity }
]}
>
<View style={styles.speedActivatedContainer}>
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
</View>
</Animated.View>
);
};
export default SpeedActivatedOverlay;

View file

@ -0,0 +1,8 @@
/**
* Shared Player Components
* Export all reusable components for both Android and iOS players
*/
export { SpeedActivatedOverlay } from './SpeedActivatedOverlay';
export { PauseOverlay } from './PauseOverlay';
export { GestureControls } from './GestureControls';

View file

@ -0,0 +1,21 @@
/**
* Shared Player Hooks
* Export all reusable hooks for both Android and iOS players
*/
// State Management
export { usePlayerState, type PlayerResizeMode } from './usePlayerState';
export { usePlayerModals } from './usePlayerModals';
export { usePlayerTracks } from './usePlayerTracks';
export { useCustomSubtitles } from './useCustomSubtitles';
// Controls & Playback
export { usePlayerControls } from './usePlayerControls';
export { useSpeedControl } from './useSpeedControl';
// Animation & UI
export { useOpeningAnimation } from './useOpeningAnimation';
export { usePlayerSetup } from './usePlayerSetup';
// Content
export { useNextEpisode } from './useNextEpisode';

View file

@ -0,0 +1,62 @@
/**
* Shared Custom Subtitles Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState } from 'react';
import {
DEFAULT_SUBTITLE_SIZE,
SubtitleCue,
SubtitleSegment,
WyzieSubtitle
} from '../utils/playerTypes';
export const useCustomSubtitles = () => {
// Data State
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
// Loading State
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
// Styling State
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
const [subtitleTextColor, setSubtitleTextColor] = useState<string>('#FFFFFF');
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState<number>(0.7);
const [subtitleTextShadow, setSubtitleTextShadow] = useState<boolean>(true);
const [subtitleOutline, setSubtitleOutline] = useState<boolean>(true);
const [subtitleOutlineColor, setSubtitleOutlineColor] = useState<string>('#000000');
const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState<number>(4);
const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center');
const [subtitleBottomOffset, setSubtitleBottomOffset] = useState<number>(10);
const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState<number>(0);
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState<number>(1.2);
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState<number>(0);
return {
customSubtitles, setCustomSubtitles,
currentSubtitle, setCurrentSubtitle,
currentFormattedSegments, setCurrentFormattedSegments,
availableSubtitles, setAvailableSubtitles,
useCustomSubtitles, setUseCustomSubtitles,
isLoadingSubtitles, setIsLoadingSubtitles,
isLoadingSubtitleList, setIsLoadingSubtitleList,
subtitleSize, setSubtitleSize,
subtitleBackground, setSubtitleBackground,
subtitleTextColor, setSubtitleTextColor,
subtitleBgOpacity, setSubtitleBgOpacity,
subtitleTextShadow, setSubtitleTextShadow,
subtitleOutline, setSubtitleOutline,
subtitleOutlineColor, setSubtitleOutlineColor,
subtitleOutlineWidth, setSubtitleOutlineWidth,
subtitleAlign, setSubtitleAlign,
subtitleBottomOffset, setSubtitleBottomOffset,
subtitleLetterSpacing, setSubtitleLetterSpacing,
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier,
subtitleOffsetSec, setSubtitleOffsetSec
};
};

View file

@ -0,0 +1,65 @@
/**
* Shared Next Episode Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useMemo } from 'react';
interface NextEpisodeConfig {
type: string | undefined;
season: number | undefined;
episode: number | undefined;
groupedEpisodes: Record<string, any[]> | undefined;
episodeId?: string;
}
export const useNextEpisode = (config: NextEpisodeConfig) => {
const { type, season, episode, groupedEpisodes, episodeId } = config;
// Current description
const currentEpisodeDescription = useMemo(() => {
try {
if (type !== 'series') return '';
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return '';
let match: any | null = null;
if (episodeId) {
match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId));
}
if (!match && season && episode) {
match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode);
}
return (match?.overview || '').trim();
} catch {
return '';
}
}, [type, groupedEpisodes, episodeId, season, episode]);
// Next Episode
const nextEpisode = useMemo(() => {
try {
if (type !== 'series' || !season || !episode) return null;
const sourceGroups = groupedEpisodes || {};
const allEpisodes = Object.values(sourceGroups).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return null;
// Try to find next episode in same season
let nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season && ep.episode_number === episode + 1
);
// If not found, try first episode of next season
if (!nextEp) {
nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season + 1 && ep.episode_number === 1
);
}
return nextEp;
} catch {
return null;
}
}, [type, season, episode, groupedEpisodes]);
return { currentEpisodeDescription, nextEpisode };
};

View file

@ -0,0 +1,152 @@
/**
* Shared Opening Animation Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useRef, useState, useEffect } from 'react';
import { Animated, InteractionManager } from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { logger } from '../../../utils/logger';
export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => {
// Animation Values
const fadeAnim = useRef(new Animated.Value(1)).current;
const openingFadeAnim = useRef(new Animated.Value(0)).current;
const openingScaleAnim = useRef(new Animated.Value(0.8)).current;
const backgroundFadeAnim = useRef(new Animated.Value(1)).current;
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false);
const [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
// Prefetch Background
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
if (backdrop && typeof backdrop === 'string') {
setIsBackdropLoaded(false);
backdropImageOpacityAnim.setValue(0);
try {
FastImage.preload([{ uri: backdrop }]);
setIsBackdropLoaded(true);
Animated.timing(backdropImageOpacityAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}).start();
} catch (error) {
setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(1);
}
} else {
setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(0);
}
});
return () => task.cancel();
}, [backdrop]);
// Prefetch Logo
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
const logoUrl = metadata?.logo;
if (logoUrl && typeof logoUrl === 'string') {
try {
FastImage.preload([{ uri: logoUrl }]);
} catch (error) { }
}
});
return () => task.cancel();
}, [metadata]);
const startOpeningAnimation = () => {
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 80,
friction: 8,
useNativeDriver: true,
}),
]).start();
const createPulseAnimation = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]);
};
const loopPulse = () => {
createPulseAnimation().start(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
});
};
loopPulse();
};
const completeOpeningAnimation = () => {
pulseAnim.stopAnimation();
Animated.parallel([
Animated.timing(openingFadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(openingScaleAnim, {
toValue: 1,
duration: 350,
useNativeDriver: true,
}),
Animated.timing(backgroundFadeAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start(() => {
setIsOpeningAnimationComplete(true);
setTimeout(() => {
setShouldHideOpeningOverlay(true);
}, 450);
});
setTimeout(() => {
if (!isOpeningAnimationComplete) {
setIsOpeningAnimationComplete(true);
}
}, 1000);
};
return {
fadeAnim,
openingFadeAnim,
openingScaleAnim,
backgroundFadeAnim,
backdropImageOpacityAnim,
logoScaleAnim,
logoOpacityAnim,
pulseAnim,
isOpeningAnimationComplete,
shouldHideOpeningOverlay,
isBackdropLoaded,
startOpeningAnimation,
completeOpeningAnimation
};
};

View file

@ -0,0 +1,81 @@
/**
* Shared Player Controls Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useRef, useCallback, MutableRefObject } from 'react';
import { Platform } from 'react-native';
import { logger } from '../../../utils/logger';
const DEBUG_MODE = false;
const END_EPSILON = 0.3;
interface PlayerControlsConfig {
playerRef: MutableRefObject<any>;
paused: boolean;
setPaused: (paused: boolean) => void;
currentTime: number;
duration: number;
isSeeking: MutableRefObject<boolean>;
isMounted: MutableRefObject<boolean>;
}
export const usePlayerControls = (config: PlayerControlsConfig) => {
const {
playerRef,
paused,
setPaused,
currentTime,
duration,
isSeeking,
isMounted
} = config;
// iOS seeking helpers
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
const togglePlayback = useCallback(() => {
setPaused(!paused);
}, [paused, setPaused]);
const seekToTime = useCallback((rawSeconds: number) => {
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
if (playerRef.current && duration > 0 && !isSeeking.current) {
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
isSeeking.current = true;
// iOS optimization: pause while seeking for smoother experience
if (Platform.OS === 'ios') {
iosWasPausedDuringSeekRef.current = paused;
if (!paused) setPaused(true);
}
// Actually perform the seek
playerRef.current.seek(timeInSeconds);
// Debounce the seeking state reset
setTimeout(() => {
if (isMounted.current && isSeeking.current) {
isSeeking.current = false;
// Resume if it was playing (iOS specific)
if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) {
setPaused(false);
iosWasPausedDuringSeekRef.current = null;
}
}
}, 500);
}
}, [duration, paused, setPaused, playerRef, isSeeking, isMounted]);
const skip = useCallback((seconds: number) => {
seekToTime(currentTime + seconds);
}, [currentTime, seekToTime]);
return {
togglePlayback,
seekToTime,
skip,
iosWasPausedDuringSeekRef
};
};

View file

@ -0,0 +1,38 @@
/**
* Shared Player Modals Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState } from 'react';
import { Episode } from '../../../types/metadata';
export const usePlayerModals = () => {
const [showAudioModal, setShowAudioModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const [showSpeedModal, setShowSpeedModal] = useState(false);
const [showSourcesModal, setShowSourcesModal] = useState(false);
const [showEpisodesModal, setShowEpisodesModal] = useState(false);
const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
const [showCastDetails, setShowCastDetails] = useState(false);
// Some modals have associated data
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
const [errorDetails, setErrorDetails] = useState<string>('');
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
return {
showAudioModal, setShowAudioModal,
showSubtitleModal, setShowSubtitleModal,
showSpeedModal, setShowSpeedModal,
showSourcesModal, setShowSourcesModal,
showEpisodesModal, setShowEpisodesModal,
showEpisodeStreamsModal, setShowEpisodeStreamsModal,
showErrorModal, setShowErrorModal,
showSubtitleLanguageModal, setShowSubtitleLanguageModal,
showCastDetails, setShowCastDetails,
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
errorDetails, setErrorDetails,
selectedCastMember, setSelectedCastMember
};
};

View file

@ -0,0 +1,117 @@
/**
* Shared Player Setup Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
* Handles StatusBar, orientation, brightness, and app state
*/
import { useEffect, useRef, useCallback } from 'react';
import { StatusBar, Dimensions, AppState, InteractionManager, Platform } from 'react-native';
import * as Brightness from 'expo-brightness';
import * as ScreenOrientation from 'expo-screen-orientation';
import { logger } from '../../../utils/logger';
import { useFocusEffect } from '@react-navigation/native';
interface PlayerSetupConfig {
setScreenDimensions: (dim: any) => void;
setVolume: (vol: number) => void;
setBrightness: (bri: number) => void;
isOpeningAnimationComplete: boolean;
}
export const usePlayerSetup = (config: PlayerSetupConfig) => {
const {
setScreenDimensions,
setVolume,
setBrightness,
isOpeningAnimationComplete
} = config;
const isAppBackgrounded = useRef(false);
const enableImmersiveMode = () => {
StatusBar.setHidden(true, 'none');
};
const disableImmersiveMode = () => {
StatusBar.setHidden(false, 'fade');
};
useFocusEffect(
useCallback(() => {
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
return () => { };
}, [isOpeningAnimationComplete])
);
useEffect(() => {
// Initial Setup
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
setScreenDimensions(screen);
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
});
StatusBar.setHidden(true, 'none');
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
// Initialize volume (normalized 0-1 for cross-platform)
setVolume(1.0);
// Initialize Brightness
const initBrightness = () => {
InteractionManager.runAfterInteractions(async () => {
try {
const currentBrightness = await Brightness.getBrightnessAsync();
setBrightness(currentBrightness);
} catch (error) {
logger.warn('[usePlayerSetup] Error getting initial brightness:', error);
setBrightness(1.0);
}
});
};
initBrightness();
return () => {
subscription?.remove();
disableImmersiveMode();
};
}, [isOpeningAnimationComplete]);
// Handle Orientation (Lock to Landscape after opening)
useEffect(() => {
if (isOpeningAnimationComplete) {
const task = InteractionManager.runAfterInteractions(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
.then(() => {
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
})
.catch((error) => {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
});
});
return () => task.cancel();
}
}, [isOpeningAnimationComplete]);
// Handle App State
useEffect(() => {
const onAppStateChange = (state: string) => {
if (state === 'active') {
isAppBackgrounded.current = false;
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
} else if (state === 'background' || state === 'inactive') {
isAppBackgrounded.current = true;
}
};
const sub = AppState.addEventListener('change', onAppStateChange);
return () => sub.remove();
}, [isOpeningAnimationComplete]);
return { isAppBackgrounded };
};

View file

@ -0,0 +1,88 @@
/**
* Shared Player State Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState, useRef } from 'react';
import { Dimensions, Platform } from 'react-native';
// Use only resize modes supported by all player backends
// (not all players support 'stretch' or 'none')
export type PlayerResizeMode = 'contain' | 'cover';
export const usePlayerState = () => {
// Playback State
const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [isBuffering, setIsBuffering] = useState(false);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPlayerReady, setIsPlayerReady] = useState(false);
// UI State
const [showControls, setShowControls] = useState(true);
const [resizeMode, setResizeMode] = useState<PlayerResizeMode>('contain');
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
const [is16by9Content, setIs16by9Content] = useState(false);
const screenData = Dimensions.get('screen');
const [screenDimensions, setScreenDimensions] = useState(screenData);
// Zoom State
const [zoomScale, setZoomScale] = useState(1);
const [zoomTranslateX, setZoomTranslateX] = useState(0);
const [zoomTranslateY, setZoomTranslateY] = useState(0);
const [lastZoomScale, setLastZoomScale] = useState(1);
const [lastTranslateX, setLastTranslateX] = useState(0);
const [lastTranslateY, setLastTranslateY] = useState(0);
// AirPlay State (iOS only, but keeping it here for unified interface)
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
// Logic State
const isSeeking = useRef(false);
const isDragging = useRef(false);
const isMounted = useRef(true);
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
const pendingSeekValue = useRef<number | null>(null);
const lastSeekTime = useRef<number>(0);
const wasPlayingBeforeDragRef = useRef<boolean>(false);
// Helper for iPad/macOS fullscreen
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
const shouldUseFullscreen = isIPad || isMacOS;
const windowData = Dimensions.get('window');
const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions;
return {
paused, setPaused,
currentTime, setCurrentTime,
duration, setDuration,
buffered, setBuffered,
isBuffering, setIsBuffering,
isVideoLoaded, setIsVideoLoaded,
isPlayerReady, setIsPlayerReady,
showControls, setShowControls,
resizeMode, setResizeMode,
videoAspectRatio, setVideoAspectRatio,
is16by9Content, setIs16by9Content,
screenDimensions, setScreenDimensions,
zoomScale, setZoomScale,
zoomTranslateX, setZoomTranslateX,
zoomTranslateY, setZoomTranslateY,
lastZoomScale, setLastZoomScale,
lastTranslateX, setLastTranslateX,
lastTranslateY, setLastTranslateY,
isAirPlayActive, setIsAirPlayActive,
allowsAirPlay, setAllowsAirPlay,
isSeeking,
isDragging,
isMounted,
seekDebounceTimer,
pendingSeekValue,
lastSeekTime,
wasPlayingBeforeDragRef,
effectiveDimensions
};
};

View file

@ -0,0 +1,47 @@
/**
* Shared Player Tracks Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState, useCallback } from 'react';
import { AudioTrack, TextTrack } from '../utils/playerTypes';
export const usePlayerTracks = () => {
// React-native-video style tracks
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
// KS/VLC style tracks (simpler format)
const [ksAudioTracks, setKsAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [ksTextTracks, setKsTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
// Derived states
const hasAudioTracks = audioTracks.length > 0 || ksAudioTracks.length > 0;
const hasTextTracks = textTracks.length > 0 || ksTextTracks.length > 0;
// Track selection functions
const selectAudioTrack = useCallback((trackId: number) => {
setSelectedAudioTrack(trackId);
}, []);
const selectTextTrack = useCallback((trackId: number) => {
setSelectedTextTrack(trackId);
}, []);
return {
// Standard tracks
audioTracks, setAudioTracks,
selectedAudioTrack, setSelectedAudioTrack,
textTracks, setTextTracks,
selectedTextTrack, setSelectedTextTrack,
// KS/VLC tracks
ksAudioTracks, setKsAudioTracks,
ksTextTracks, setKsTextTracks,
// Helpers
hasAudioTracks,
hasTextTracks,
selectAudioTrack,
selectTextTrack
};
};

View file

@ -0,0 +1,97 @@
/**
* Shared Speed Control Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { Animated } from 'react-native';
import { mmkvStorage } from '../../../services/mmkvStorage';
import { logger } from '../../../utils/logger';
const SPEED_SETTINGS_KEY = '@nuvio_speed_settings';
export const useSpeedControl = (initialSpeed: number = 1.0) => {
const [playbackSpeed, setPlaybackSpeed] = useState<number>(initialSpeed);
const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true);
const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0);
const [isSpeedBoosted, setIsSpeedBoosted] = useState(false);
const [originalSpeed, setOriginalSpeed] = useState<number>(initialSpeed);
const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false);
const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current;
// Load Settings
useEffect(() => {
const loadSettings = async () => {
try {
const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY);
if (saved) {
const settings = JSON.parse(saved);
if (typeof settings.holdToSpeedEnabled === 'boolean') setHoldToSpeedEnabled(settings.holdToSpeedEnabled);
if (typeof settings.holdToSpeedValue === 'number') setHoldToSpeedValue(settings.holdToSpeedValue);
}
} catch (e) {
logger.warn('[useSpeedControl] Error loading settings', e);
}
};
loadSettings();
}, []);
// Save Settings
useEffect(() => {
const saveSettings = async () => {
try {
await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({
holdToSpeedEnabled,
holdToSpeedValue
}));
} catch (e) { }
};
saveSettings();
}, [holdToSpeedEnabled, holdToSpeedValue]);
const activateSpeedBoost = useCallback(() => {
if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return;
setOriginalSpeed(playbackSpeed);
setPlaybackSpeed(holdToSpeedValue);
setIsSpeedBoosted(true);
setShowSpeedActivatedOverlay(true);
Animated.timing(speedActivatedOverlayOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true
}).start();
setTimeout(() => {
Animated.timing(speedActivatedOverlayOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true
}).start(() => setShowSpeedActivatedOverlay(false));
}, 2000);
}, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]);
const deactivateSpeedBoost = useCallback(() => {
if (isSpeedBoosted) {
setPlaybackSpeed(originalSpeed);
setIsSpeedBoosted(false);
Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start();
}
}, [isSpeedBoosted, originalSpeed]);
return {
playbackSpeed,
setPlaybackSpeed,
holdToSpeedEnabled,
setHoldToSpeedEnabled,
holdToSpeedValue,
setHoldToSpeedValue,
isSpeedBoosted,
activateSpeedBoost,
deactivateSpeedBoost,
showSpeedActivatedOverlay,
speedActivatedOverlayOpacity
};
};

View file

@ -0,0 +1,333 @@
import React from 'react';
import { View, Text, Animated } from 'react-native';
import {
TapGestureHandler,
PanGestureHandler,
LongPressGestureHandler,
} from 'react-native-gesture-handler';
import { MaterialIcons } from '@expo/vector-icons';
interface GestureControlsProps {
screenDimensions: { width: number, height: number };
gestureControls: any;
onLongPressActivated: () => void;
onLongPressEnd: () => void;
onLongPressStateChange: (event: any) => void;
toggleControls: () => void;
showControls: boolean;
hideControls: () => void;
volume: number;
brightness: number;
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
}
export const GestureControls: React.FC<GestureControlsProps> = ({
screenDimensions,
gestureControls,
onLongPressActivated,
onLongPressEnd,
onLongPressStateChange,
toggleControls,
showControls,
hideControls,
volume,
brightness,
controlsTimeout
}) => {
// Helper to get dimensions (using passed screenDimensions)
const getDimensions = () => screenDimensions;
return (
<>
{/* Left side gesture handler - brightness + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onBrightnessGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Right side gesture handler - volume + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onVolumeGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
right: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Center area tap handler */}
<TapGestureHandler
onActivated={() => {
if (showControls) {
const timeoutId = setTimeout(() => {
hideControls();
}, 0);
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
}
controlsTimeout.current = timeoutId;
} else {
toggleControls();
}
}}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: screenDimensions.width * 0.4,
width: screenDimensions.width * 0.2,
height: screenDimensions.height * 0.7,
zIndex: 5,
}} />
</TapGestureHandler>
{/* Volume Overlay */}
{gestureControls.showVolumeOverlay && (
<Animated.View
style={{
position: 'absolute',
left: getDimensions().width / 2 - 60,
top: getDimensions().height / 2 - 60,
opacity: gestureControls.volumeOverlayOpacity,
zIndex: 1000,
}}
>
<View style={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderRadius: 12,
padding: 16,
alignItems: 'center',
width: 120,
height: 120,
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}}>
<MaterialIcons
name={volume === 0 ? "volume-off" : volume < 30 ? "volume-mute" : volume < 70 ? "volume-down" : "volume-up"}
size={24}
color={volume === 0 ? "#FF6B6B" : "#FFFFFF"}
style={{ marginBottom: 8 }}
/>
{/* Horizontal Dotted Progress Bar */}
<View style={{
width: 80,
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 3,
position: 'relative',
overflow: 'hidden',
marginBottom: 8,
}}>
{/* Dotted background */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 1,
}}>
{Array.from({ length: 16 }, (_, i) => (
<View
key={i}
style={{
width: 1.5,
height: 1.5,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
borderRadius: 0.75,
}}
/>
))}
</View>
{/* Progress fill */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: `${volume}%`,
height: 6,
backgroundColor: volume === 0 ? '#FF6B6B' : '#E50914',
borderRadius: 3,
shadowColor: volume === 0 ? '#FF6B6B' : '#E50914',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.6,
shadowRadius: 2,
}} />
</View>
<Text style={{
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
}}>
{Math.round(volume)}%
</Text>
</View>
</Animated.View>
)}
{/* Brightness Overlay */}
{gestureControls.showBrightnessOverlay && (
<Animated.View
style={{
position: 'absolute',
left: getDimensions().width / 2 - 60,
top: getDimensions().height / 2 - 60,
opacity: gestureControls.brightnessOverlayOpacity,
zIndex: 1000,
}}
>
<View style={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderRadius: 12,
padding: 16,
alignItems: 'center',
width: 120,
height: 120,
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}}>
<MaterialIcons
name={brightness < 0.2 ? "brightness-low" : brightness < 0.5 ? "brightness-medium" : brightness < 0.8 ? "brightness-high" : "brightness-auto"}
size={24}
color={brightness < 0.2 ? "#FFD700" : "#FFFFFF"}
style={{ marginBottom: 8 }}
/>
{/* Horizontal Dotted Progress Bar */}
<View style={{
width: 80,
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 3,
position: 'relative',
overflow: 'hidden',
marginBottom: 8,
}}>
{/* Dotted background */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 1,
}}>
{Array.from({ length: 16 }, (_, i) => (
<View
key={i}
style={{
width: 1.5,
height: 1.5,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
borderRadius: 0.75,
}}
/>
))}
</View>
{/* Progress fill */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: `${brightness * 100}%`,
height: 6,
backgroundColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
borderRadius: 3,
shadowColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.6,
shadowRadius: 2,
}} />
</View>
<Text style={{
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
}}>
{Math.round(brightness * 100)}%
</Text>
</View>
</Animated.View>
)}
</>
);
};

View file

@ -0,0 +1,140 @@
import React, { useRef } from 'react';
import { Animated } from 'react-native';
import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import KSPlayerComponent, { KSPlayerRef, KSPlayerSource } from '../../KSPlayerComponent';
interface KSPlayerSurfaceProps {
ksPlayerRef: React.RefObject<KSPlayerRef>;
uri: string;
headers?: Record<string, string>;
paused: boolean;
volume: number;
playbackSpeed: number;
resizeMode: 'contain' | 'cover' | 'stretch';
zoomScale: number;
setZoomScale: (scale: number) => void;
lastZoomScale: number;
setLastZoomScale: (scale: number) => void;
// Tracks - use number directly
audioTrack?: number;
textTrack?: number;
onAudioTracks: (data: any) => void;
onTextTracks: (data: any) => void;
// Handlers
onLoad: (data: any) => void;
onProgress: (data: any) => void;
onEnd: () => void;
onError: (error: any) => void;
onBuffer: (isBuffering: boolean) => void;
onReadyForDisplay: () => void;
onPlaybackStalled: () => void;
onPlaybackResume: () => void;
// Dimensions
screenWidth: number;
screenHeight: number;
customVideoStyles: any;
}
export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
ksPlayerRef,
uri,
headers,
paused,
volume,
playbackSpeed,
resizeMode,
zoomScale,
setZoomScale,
lastZoomScale,
setLastZoomScale,
audioTrack,
textTrack,
onAudioTracks,
onTextTracks,
onLoad,
onProgress,
onEnd,
onError,
onBuffer,
onReadyForDisplay,
onPlaybackStalled,
onPlaybackResume,
screenWidth,
screenHeight,
customVideoStyles
}) => {
const pinchRef = useRef<PinchGestureHandler>(null);
const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
const { scale } = event.nativeEvent;
// Limit max zoom to 1.1x as per original logic, min 1
const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
setZoomScale(newScale);
};
const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
if (event.nativeEvent.state === State.END) {
setLastZoomScale(zoomScale);
}
};
// Create source object for KSPlayerComponent
const source: KSPlayerSource = {
uri,
headers
};
// Handle buffering - KSPlayerComponent uses onBuffering callback
const handleBuffering = (data: any) => {
onBuffer(data?.isBuffering ?? false);
};
// Handle load - also extract tracks if available
const handleLoad = (data: any) => {
onLoad(data);
// Extract tracks if present in load data
if (data?.audioTracks) {
onAudioTracks({ audioTracks: data.audioTracks });
}
if (data?.textTracks) {
onTextTracks({ textTracks: data.textTracks });
}
// Notify ready for display
onReadyForDisplay();
};
return (
<PinchGestureHandler
ref={pinchRef}
onGestureEvent={onPinchGestureEvent}
onHandlerStateChange={onPinchHandlerStateChange}
>
<Animated.View style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
transform: [{ scale: zoomScale }]
}}>
<KSPlayerComponent
ref={ksPlayerRef}
source={source}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={resizeMode}
audioTrack={audioTrack}
textTrack={textTrack}
onLoad={handleLoad}
onProgress={onProgress}
onBuffering={handleBuffering}
onEnd={onEnd}
onError={onError}
style={customVideoStyles.width ? customVideoStyles : { width: screenWidth, height: screenHeight }}
/>
</Animated.View>
</PinchGestureHandler>
);
};

View file

@ -0,0 +1,228 @@
import React, { useState, useRef } from 'react';
import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface PauseOverlayProps {
visible: boolean;
onClose: () => void;
title: string;
episodeTitle?: string;
season?: number;
episode?: number;
year?: string | number;
type: string;
description: string;
cast: any[];
screenDimensions: { width: number, height: number };
}
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
visible,
onClose,
title,
episodeTitle,
season,
episode,
year,
type,
description,
cast,
screenDimensions
}) => {
const insets = useSafeAreaInsets();
// Internal Animation State
const pauseOverlayOpacity = useRef(new Animated.Value(visible ? 1 : 0)).current;
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
const metadataOpacity = useRef(new Animated.Value(1)).current;
const metadataScale = useRef(new Animated.Value(1)).current;
// Cast Details State
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [showCastDetails, setShowCastDetails] = useState(false);
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
React.useEffect(() => {
Animated.timing(pauseOverlayOpacity, {
toValue: visible ? 1 : 0,
duration: 250,
useNativeDriver: true
}).start();
}, [visible]);
if (!visible && !showCastDetails) return null;
return (
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: pauseOverlayOpacity,
}}
>
{/* Horizontal Fade */}
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
<LinearGradient
colors={[
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.0)'
]}
locations={[0, 0.3, 0.6, 1]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={{
position: 'absolute',
left: 24 + insets.left,
right: 24 + insets.right,
top: 24 + insets.top,
bottom: 110 + insets.bottom,
transform: [{ translateY: pauseOverlayTranslateY }]
}}>
{showCastDetails && selectedCastMember ? (
<Animated.View
style={{
flex: 1,
justifyContent: 'center',
opacity: castDetailsOpacity,
transform: [{ scale: castDetailsScale }]
}}
>
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
<TouchableOpacity
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
onPress={() => {
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(false);
setSelectedCastMember(null);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
{selectedCastMember.profile_path && (
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
)}
<View style={{ flex: 1, paddingTop: 8 }}>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
{selectedCastMember.name}
</Text>
{selectedCastMember.character && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
as {selectedCastMember.character}
</Text>
)}
{selectedCastMember.biography && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
{selectedCastMember.biography}
</Text>
)}
</View>
</View>
</View>
</Animated.View>
) : (
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
<View>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
{title}
</Text>
{!!year && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
</Text>
)}
{!!episodeTitle && (
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
{episodeTitle}
</Text>
)}
{description && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
{description}
</Text>
)}
{cast && cast.length > 0 && (
<View style={{ marginTop: 16 }}>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{cast.slice(0, 6).map((castMember: any, index: number) => (
<TouchableOpacity
key={castMember.id || index}
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
onPress={() => {
setSelectedCastMember(castMember);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(true);
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
{castMember.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
</Animated.View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
};

View file

@ -0,0 +1,32 @@
import React from 'react';
import { View, Text, Animated } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { styles } from '../../utils/playerStyles';
interface SpeedActivatedOverlayProps {
visible: boolean;
opacity: Animated.Value;
speed: number;
}
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
visible,
opacity,
speed
}) => {
if (!visible) return null;
return (
<Animated.View
style={[
styles.speedActivatedOverlay,
{ opacity: opacity }
]}
>
<View style={styles.speedActivatedContainer}>
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
</View>
</Animated.View>
);
};

View file

@ -0,0 +1,58 @@
import { useState } from 'react';
import {
DEFAULT_SUBTITLE_SIZE,
SubtitleCue,
SubtitleSegment,
WyzieSubtitle
} from '../../utils/playerTypes';
export const useCustomSubtitles = () => {
// Data State
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
// Loading State
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
// Styling State
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
const [subtitleTextColor, setSubtitleTextColor] = useState<string>('#FFFFFF');
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState<number>(0.7);
const [subtitleTextShadow, setSubtitleTextShadow] = useState<boolean>(true);
const [subtitleOutline, setSubtitleOutline] = useState<boolean>(true);
const [subtitleOutlineColor, setSubtitleOutlineColor] = useState<string>('#000000');
const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState<number>(4);
const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center');
const [subtitleBottomOffset, setSubtitleBottomOffset] = useState<number>(10);
const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState<number>(0);
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState<number>(1.2);
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState<number>(0);
return {
customSubtitles, setCustomSubtitles,
currentSubtitle, setCurrentSubtitle,
currentFormattedSegments, setCurrentFormattedSegments,
availableSubtitles, setAvailableSubtitles,
useCustomSubtitles, setUseCustomSubtitles,
isLoadingSubtitles, setIsLoadingSubtitles,
isLoadingSubtitleList, setIsLoadingSubtitleList,
subtitleSize, setSubtitleSize,
subtitleBackground, setSubtitleBackground,
subtitleTextColor, setSubtitleTextColor,
subtitleBgOpacity, setSubtitleBgOpacity,
subtitleTextShadow, setSubtitleTextShadow,
subtitleOutline, setSubtitleOutline,
subtitleOutlineColor, setSubtitleOutlineColor,
subtitleOutlineWidth, setSubtitleOutlineWidth,
subtitleAlign, setSubtitleAlign,
subtitleBottomOffset, setSubtitleBottomOffset,
subtitleLetterSpacing, setSubtitleLetterSpacing,
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier,
subtitleOffsetSec, setSubtitleOffsetSec
};
};

View file

@ -0,0 +1,15 @@
import { useRef } from 'react';
import { KSPlayerRef } from '../../KSPlayerComponent';
export const useKSPlayer = () => {
const ksPlayerRef = useRef<KSPlayerRef>(null);
const seek = (time: number) => {
ksPlayerRef.current?.seek(time);
};
return {
ksPlayerRef,
seek
};
};

View file

@ -0,0 +1,58 @@
import { useMemo } from 'react';
export const useNextEpisode = (
type: string | undefined,
season: number | undefined,
episode: number | undefined,
groupedEpisodes: any,
metadataGroupedEpisodes: any,
episodeId: string | undefined
) => {
// Current description
const currentEpisodeDescription = useMemo(() => {
try {
if (type !== 'series') return '';
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return '';
let match: any | null = null;
if (episodeId) {
match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId));
}
if (!match && season && episode) {
match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode);
}
return (match?.overview || '').trim();
} catch {
return '';
}
}, [type, groupedEpisodes, episodeId, season, episode]);
// Next Episode
const nextEpisode = useMemo(() => {
try {
if (type !== 'series' || !season || !episode) return null;
const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0
? groupedEpisodes
: (metadataGroupedEpisodes || {});
const allEpisodes = Object.values(sourceGroups || {}).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return null;
let nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season && ep.episode_number === episode + 1
);
if (!nextEp) {
nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season + 1 && ep.episode_number === 1
);
}
return nextEp;
} catch {
return null;
}
}, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]);
return { currentEpisodeDescription, nextEpisode };
};

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('[VideoPlayer] 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,63 @@
import { useRef, useCallback } from 'react';
import { Platform } from 'react-native';
import { logger } from '../../../../utils/logger';
const DEBUG_MODE = false;
const END_EPSILON = 0.3;
export const usePlayerControls = (
ksPlayerRef: any,
paused: boolean,
setPaused: (paused: boolean) => void,
currentTime: number,
duration: number,
isSeeking: React.MutableRefObject<boolean>,
isMounted: React.MutableRefObject<boolean>
) => {
// iOS seeking helpers
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
const togglePlayback = useCallback(() => {
setPaused(!paused);
}, [paused, setPaused]);
const seekToTime = useCallback((rawSeconds: number) => {
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
if (ksPlayerRef.current && duration > 0 && !isSeeking.current) {
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
isSeeking.current = true;
// iOS optimization: pause while seeking for smoother experience
iosWasPausedDuringSeekRef.current = paused;
if (!paused) setPaused(true);
// Actually perform the seek
ksPlayerRef.current.seek(timeInSeconds);
// Debounce the seeking state reset
setTimeout(() => {
if (isMounted.current && isSeeking.current) {
isSeeking.current = false;
// Resume if it was playing
if (iosWasPausedDuringSeekRef.current === false) {
setPaused(false);
iosWasPausedDuringSeekRef.current = null;
}
}
}, 500);
}
}, [duration, paused, setPaused, ksPlayerRef, isSeeking, isMounted]);
const skip = useCallback((seconds: number) => {
seekToTime(currentTime + seconds);
}, [currentTime, seekToTime]);
return {
togglePlayback,
seekToTime,
skip,
iosWasPausedDuringSeekRef
};
};

View file

@ -0,0 +1,34 @@
import { useState } from 'react';
import { Episode } from '../../../../types/metadata';
export const usePlayerModals = () => {
const [showAudioModal, setShowAudioModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const [showSpeedModal, setShowSpeedModal] = useState(false);
const [showSourcesModal, setShowSourcesModal] = useState(false);
const [showEpisodesModal, setShowEpisodesModal] = useState(false);
const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
const [showCastDetails, setShowCastDetails] = useState(false);
// Some modals have associated data
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
const [errorDetails, setErrorDetails] = useState<string>('');
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
return {
showAudioModal, setShowAudioModal,
showSubtitleModal, setShowSubtitleModal,
showSpeedModal, setShowSpeedModal,
showSourcesModal, setShowSourcesModal,
showEpisodesModal, setShowEpisodesModal,
showEpisodeStreamsModal, setShowEpisodeStreamsModal,
showErrorModal, setShowErrorModal,
showSubtitleLanguageModal, setShowSubtitleLanguageModal,
showCastDetails, setShowCastDetails,
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
errorDetails, setErrorDetails,
selectedCastMember, setSelectedCastMember
};
};

View file

@ -0,0 +1,103 @@
import { useEffect, useRef, useCallback } from 'react';
import { StatusBar, Dimensions, AppState, InteractionManager } from 'react-native';
import * as Brightness from 'expo-brightness';
import * as ScreenOrientation from 'expo-screen-orientation';
import { logger } from '../../../../utils/logger';
import { useFocusEffect } from '@react-navigation/native';
export const usePlayerSetup = (
setScreenDimensions: (dim: any) => void,
setVolume: (vol: number) => void,
setBrightness: (bri: number) => void,
isOpeningAnimationComplete: boolean
) => {
const isAppBackgrounded = useRef(false);
const enableImmersiveMode = () => {
StatusBar.setHidden(true, 'none');
};
const disableImmersiveMode = () => {
StatusBar.setHidden(false, 'fade');
};
useFocusEffect(
useCallback(() => {
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
return () => { };
}, [isOpeningAnimationComplete])
);
useEffect(() => {
// Initial Setup
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
setScreenDimensions(screen);
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
});
StatusBar.setHidden(true, 'none');
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
// Initialize volume (KSPlayer uses 0-100)
setVolume(100);
// Initialize Brightness
const initBrightness = () => {
InteractionManager.runAfterInteractions(async () => {
try {
const currentBrightness = await Brightness.getBrightnessAsync();
setBrightness(currentBrightness);
} catch (error) {
logger.warn('[usePlayerSetup] Error getting initial brightness:', error);
setBrightness(1.0);
}
});
};
initBrightness();
return () => {
subscription?.remove();
disableImmersiveMode();
};
}, [isOpeningAnimationComplete]);
// Handle Orientation (Lock to Landscape after opening)
useEffect(() => {
if (isOpeningAnimationComplete) {
const task = InteractionManager.runAfterInteractions(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
.then(() => {
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
})
.catch((error) => {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
});
});
return () => task.cancel();
}
}, [isOpeningAnimationComplete]);
// Handle App State
useEffect(() => {
const onAppStateChange = (state: string) => {
if (state === 'active') {
isAppBackgrounded.current = false;
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
} else if (state === 'background' || state === 'inactive') {
isAppBackgrounded.current = true;
}
};
const sub = AppState.addEventListener('change', onAppStateChange);
return () => sub.remove();
}, [isOpeningAnimationComplete]);
return { isAppBackgrounded };
};

View file

@ -0,0 +1,83 @@
import { useState, useRef } from 'react';
import { Dimensions, Platform } from 'react-native';
// Use a specific type for resizeMode that matches what KSPlayerComponent supports
type PlayerResizeMode = 'contain' | 'cover' | 'stretch';
export const usePlayerState = () => {
// Playback State
const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [isBuffering, setIsBuffering] = useState(false);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPlayerReady, setIsPlayerReady] = useState(false);
// UI State
const [showControls, setShowControls] = useState(true);
const [resizeMode, setResizeMode] = useState<PlayerResizeMode>('contain');
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
const [is16by9Content, setIs16by9Content] = useState(false);
const screenData = Dimensions.get('screen');
const [screenDimensions, setScreenDimensions] = useState(screenData);
// Zoom State
const [zoomScale, setZoomScale] = useState(1);
const [zoomTranslateX, setZoomTranslateX] = useState(0);
const [zoomTranslateY, setZoomTranslateY] = useState(0);
const [lastZoomScale, setLastZoomScale] = useState(1);
const [lastTranslateX, setLastTranslateX] = useState(0);
const [lastTranslateY, setLastTranslateY] = useState(0);
// AirPlay State
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
// Logic State
const isSeeking = useRef(false);
const isDragging = useRef(false);
const isMounted = useRef(true);
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
const pendingSeekValue = useRef<number | null>(null);
const lastSeekTime = useRef<number>(0);
const wasPlayingBeforeDragRef = useRef<boolean>(false);
// Helper for iPad/macOS fullscreen
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
const shouldUseFullscreen = isIPad || isMacOS;
const windowData = Dimensions.get('window');
const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions;
return {
paused, setPaused,
currentTime, setCurrentTime,
duration, setDuration,
buffered, setBuffered,
isBuffering, setIsBuffering,
isVideoLoaded, setIsVideoLoaded,
isPlayerReady, setIsPlayerReady,
showControls, setShowControls,
resizeMode, setResizeMode,
videoAspectRatio, setVideoAspectRatio,
is16by9Content, setIs16by9Content,
screenDimensions, setScreenDimensions,
zoomScale, setZoomScale,
zoomTranslateX, setZoomTranslateX,
zoomTranslateY, setZoomTranslateY,
lastZoomScale, setLastZoomScale,
lastTranslateX, setLastTranslateX,
lastTranslateY, setLastTranslateY,
isAirPlayActive, setIsAirPlayActive,
allowsAirPlay, setAllowsAirPlay,
isSeeking,
isDragging,
isMounted,
seekDebounceTimer,
pendingSeekValue,
lastSeekTime,
wasPlayingBeforeDragRef,
effectiveDimensions
};
};

View file

@ -0,0 +1,38 @@
import { useState, useMemo, useCallback } from 'react';
import { AudioTrack, TextTrack } from '../../utils/playerTypes';
export const usePlayerTracks = () => {
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
const [ksAudioTracks, setKsAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [ksTextTracks, setKsTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
// Derived states or logic
const hasAudioTracks = audioTracks.length > 0;
const hasTextTracks = textTracks.length > 0;
// Track selection functions
const selectAudioTrack = useCallback((trackId: number) => {
setSelectedAudioTrack(trackId);
}, []);
const selectTextTrack = useCallback((trackId: number) => {
setSelectedTextTrack(trackId);
}, []);
return {
audioTracks, setAudioTracks,
selectedAudioTrack, setSelectedAudioTrack,
textTracks, setTextTracks,
selectedTextTrack, setSelectedTextTrack,
ksAudioTracks, setKsAudioTracks,
ksTextTracks, setKsTextTracks,
hasAudioTracks,
hasTextTracks,
selectAudioTrack,
selectTextTrack
};
};

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,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

@ -110,3 +110,5 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
</View>
);
};
export default AudioTrackModal;

View file

@ -374,3 +374,5 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
</View>
);
};
export default EpisodeStreamsModal;

View file

@ -170,3 +170,5 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
</View>
);
};
export default EpisodesModal;

View file

@ -276,4 +276,6 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
</Animated.View>
</View>
);
};
};
export default SourcesModal;

View file

@ -260,32 +260,37 @@ export const styles = StyleSheet.create({
},
gestureIndicatorContainer: {
position: 'absolute',
top: '40%',
left: '50%',
transform: [{ translateX: -75 }, { translateY: -40 }],
width: 150,
height: 80,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderRadius: 16,
flexDirection: 'row',
top: 40,
alignSelf: 'center',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 16,
justifyContent: 'center',
zIndex: 2000,
},
gestureIndicatorPill: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 24,
paddingVertical: 8,
paddingHorizontal: 14,
gap: 8,
},
iconWrapper: {
width: 40,
height: 40,
borderRadius: 20,
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.15)',
},
gestureText: {
color: '#FFFFFF',
fontSize: 24,
fontSize: 16,
fontWeight: '600',
minWidth: 48,
textAlign: 'center',
},
bottomControls: {

View file

@ -61,9 +61,9 @@ export interface TextTrack {
type?: string | null; // Adjusting type based on linter error
}
// Define the possible resize modes - force to stretch for absolute full screen
export type ResizeModeType = 'contain' | 'cover' | 'none';
export const resizeModes: ResizeModeType[] = ['cover']; // Force cover mode for absolute full screen
// Define the possible resize modes
export type ResizeModeType = 'contain' | 'cover' | 'stretch' | 'none';
export const resizeModes: ResizeModeType[] = ['cover', 'contain', 'stretch'];
// Add VLC specific interface for their event structure
export interface VlcMediaEvent {