1438 lines
No EOL
46 KiB
TypeScript
1438 lines
No EOL
46 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated } from 'react-native';
|
|
import Video from 'react-native-video';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { Slider } from 'react-native-awesome-slider';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { useSharedValue, runOnJS, withTiming } from 'react-native-reanimated';
|
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
|
// Remove Gesture Handler imports
|
|
// import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
|
|
// Import for navigation bar hiding
|
|
import { NativeModules } from 'react-native';
|
|
// Import immersive mode package
|
|
import RNImmersiveMode from 'react-native-immersive-mode';
|
|
// Import screen orientation lock
|
|
import * as ScreenOrientation from 'expo-screen-orientation';
|
|
// Import storage service for progress tracking
|
|
import { storageService } from '../services/storageService';
|
|
import { logger } from '../utils/logger';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
|
|
// Constants for resume preferences - add after type definitions
|
|
const RESUME_PREF_KEY = '@video_resume_preference';
|
|
const RESUME_PREF = {
|
|
ALWAYS_ASK: 'always_ask',
|
|
ALWAYS_RESUME: 'always_resume',
|
|
ALWAYS_START_OVER: 'always_start_over'
|
|
};
|
|
|
|
// Define the TrackPreferenceType for audio/text tracks
|
|
type TrackPreferenceType = 'system' | 'disabled' | 'title' | 'language' | 'index';
|
|
|
|
// Define the SelectedTrack type for audio/text tracks
|
|
interface SelectedTrack {
|
|
type: TrackPreferenceType;
|
|
value?: string | number; // value is optional for 'system' and 'disabled'
|
|
}
|
|
|
|
interface VideoPlayerProps {
|
|
uri: string;
|
|
title?: string;
|
|
season?: number;
|
|
episode?: number;
|
|
episodeTitle?: string;
|
|
quality?: string;
|
|
year?: number;
|
|
streamProvider?: string;
|
|
id?: string;
|
|
type?: string;
|
|
episodeId?: string;
|
|
}
|
|
|
|
// Match the react-native-video AudioTrack type
|
|
interface AudioTrack {
|
|
index: number;
|
|
title?: string;
|
|
language?: string;
|
|
bitrate?: number;
|
|
type?: string;
|
|
selected?: boolean;
|
|
}
|
|
|
|
// Define TextTrack interface based on react-native-video expected structure
|
|
interface TextTrack {
|
|
index: number;
|
|
title?: string;
|
|
language?: string;
|
|
type?: string | null; // Adjusting type based on linter error
|
|
}
|
|
|
|
// Define the possible resize modes
|
|
type ResizeModeType = 'contain' | 'cover' | 'stretch' | 'none';
|
|
const resizeModes: ResizeModeType[] = ['contain', 'cover', 'stretch'];
|
|
|
|
// Add language code to name mapping
|
|
const languageMap: {[key: string]: string} = {
|
|
'en': 'English',
|
|
'eng': 'English',
|
|
'es': 'Spanish',
|
|
'spa': 'Spanish',
|
|
'fr': 'French',
|
|
'fre': 'French',
|
|
'de': 'German',
|
|
'ger': 'German',
|
|
'it': 'Italian',
|
|
'ita': 'Italian',
|
|
'ja': 'Japanese',
|
|
'jpn': 'Japanese',
|
|
'ko': 'Korean',
|
|
'kor': 'Korean',
|
|
'zh': 'Chinese',
|
|
'chi': 'Chinese',
|
|
'ru': 'Russian',
|
|
'rus': 'Russian',
|
|
'pt': 'Portuguese',
|
|
'por': 'Portuguese',
|
|
'hi': 'Hindi',
|
|
'hin': 'Hindi',
|
|
'ar': 'Arabic',
|
|
'ara': 'Arabic',
|
|
'nl': 'Dutch',
|
|
'dut': 'Dutch',
|
|
'sv': 'Swedish',
|
|
'swe': 'Swedish',
|
|
'no': 'Norwegian',
|
|
'nor': 'Norwegian',
|
|
'fi': 'Finnish',
|
|
'fin': 'Finnish',
|
|
'da': 'Danish',
|
|
'dan': 'Danish',
|
|
'pl': 'Polish',
|
|
'pol': 'Polish',
|
|
'tr': 'Turkish',
|
|
'tur': 'Turkish',
|
|
'cs': 'Czech',
|
|
'cze': 'Czech',
|
|
'hu': 'Hungarian',
|
|
'hun': 'Hungarian',
|
|
'el': 'Greek',
|
|
'gre': 'Greek',
|
|
'th': 'Thai',
|
|
'tha': 'Thai',
|
|
'vi': 'Vietnamese',
|
|
'vie': 'Vietnamese',
|
|
};
|
|
|
|
// Function to format language code to readable name
|
|
const formatLanguage = (code?: string): string => {
|
|
if (!code) return 'Unknown';
|
|
const normalized = code.toLowerCase();
|
|
return languageMap[normalized] || code.toUpperCase();
|
|
};
|
|
|
|
const VideoPlayer: React.FC = () => {
|
|
const navigation = useNavigation();
|
|
const route = useRoute<RouteProp<RootStackParamList, 'Player'>>();
|
|
|
|
// Extract props from route.params
|
|
const {
|
|
uri,
|
|
title = 'Episode Name',
|
|
season,
|
|
episode,
|
|
episodeTitle,
|
|
quality,
|
|
year,
|
|
streamProvider,
|
|
id,
|
|
type,
|
|
episodeId
|
|
} = route.params;
|
|
|
|
// Log received props for debugging
|
|
logger.log("[VideoPlayer] Received props:", {
|
|
uri,
|
|
title,
|
|
season,
|
|
episode,
|
|
episodeTitle,
|
|
quality,
|
|
year,
|
|
streamProvider,
|
|
id,
|
|
type,
|
|
episodeId
|
|
});
|
|
|
|
const [paused, setPaused] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [showControls, setShowControls] = useState(true);
|
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
|
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
|
|
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
|
|
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
|
|
const [selectedTextTrack, setSelectedTextTrack] = useState<SelectedTrack | null>({ type: 'disabled' });
|
|
const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain'); // State for resize mode
|
|
const videoRef = useRef<any>(null);
|
|
const progress = useSharedValue(0);
|
|
const min = useSharedValue(0);
|
|
const max = useSharedValue(duration);
|
|
const [showAudioModal, setShowAudioModal] = useState(false);
|
|
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
|
|
|
|
// Add state for tracking initial position to seek to
|
|
const [initialPosition, setInitialPosition] = useState<number | null>(null);
|
|
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null);
|
|
const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
|
|
|
|
// Add state for showing resume overlay
|
|
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
|
|
const [resumePosition, setResumePosition] = useState<number | null>(null);
|
|
|
|
// Add state for remembering choice
|
|
const [rememberChoice, setRememberChoice] = useState(false);
|
|
const [resumePreference, setResumePreference] = useState<string | null>(null);
|
|
|
|
// Add animated value for controls opacity
|
|
const fadeAnim = useRef(new Animated.Value(1)).current;
|
|
|
|
// Add buffered state
|
|
const [buffered, setBuffered] = useState<number>(0);
|
|
|
|
// Lock screen to landscape when component mounts
|
|
useEffect(() => {
|
|
const lockToLandscape = async () => {
|
|
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
|
};
|
|
|
|
// Lock to landscape
|
|
lockToLandscape();
|
|
|
|
// Enable immersive mode when component mounts
|
|
enableImmersiveMode();
|
|
|
|
// Restore screen orientation and disable immersive mode when component unmounts
|
|
return () => {
|
|
const unlockOrientation = async () => {
|
|
await ScreenOrientation.unlockAsync();
|
|
};
|
|
unlockOrientation();
|
|
disableImmersiveMode();
|
|
};
|
|
}, []);
|
|
|
|
// Load saved watch progress on mount
|
|
useEffect(() => {
|
|
const loadWatchProgress = async () => {
|
|
if (id && type) {
|
|
try {
|
|
logger.log(`[VideoPlayer] Checking for saved progress with id=${id}, type=${type}, episodeId=${episodeId || 'none'}`);
|
|
const savedProgress = await storageService.getWatchProgress(id, type, episodeId);
|
|
|
|
if (savedProgress) {
|
|
logger.log(`[VideoPlayer] Found saved progress:`, savedProgress);
|
|
|
|
if (savedProgress.currentTime > 0) {
|
|
// Only auto-resume if less than 95% watched (not effectively complete)
|
|
const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100;
|
|
logger.log(`[VideoPlayer] Progress percent: ${progressPercent.toFixed(2)}%`);
|
|
|
|
if (progressPercent < 95) {
|
|
logger.log(`[VideoPlayer] Setting initial position to ${savedProgress.currentTime}`);
|
|
// Set resume position
|
|
setResumePosition(savedProgress.currentTime);
|
|
|
|
// Check for saved preference
|
|
const pref = await AsyncStorage.getItem(RESUME_PREF_KEY);
|
|
if (pref === RESUME_PREF.ALWAYS_RESUME) {
|
|
setInitialPosition(savedProgress.currentTime);
|
|
logger.log(`[VideoPlayer] Auto-resuming based on saved preference`);
|
|
} else if (pref === RESUME_PREF.ALWAYS_START_OVER) {
|
|
setInitialPosition(0);
|
|
logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`);
|
|
} else {
|
|
// Only show resume overlay if no preference or ALWAYS_ASK
|
|
setShowResumeOverlay(true);
|
|
}
|
|
} else {
|
|
logger.log(`[VideoPlayer] Progress >= 95%, starting from beginning`);
|
|
}
|
|
}
|
|
} else {
|
|
logger.log(`[VideoPlayer] No saved progress found`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error loading watch progress:', error);
|
|
}
|
|
} else {
|
|
logger.log(`[VideoPlayer] Missing id or type, can't load progress. id=${id}, type=${type}`);
|
|
}
|
|
};
|
|
|
|
loadWatchProgress();
|
|
}, [id, type, episodeId]);
|
|
|
|
// Set up interval to save watch progress periodically (every 5 seconds)
|
|
useEffect(() => {
|
|
if (id && type && !paused && duration > 0) {
|
|
// Clear any existing interval
|
|
if (progressSaveInterval) {
|
|
clearInterval(progressSaveInterval);
|
|
}
|
|
|
|
// Set up new interval to save progress
|
|
const interval = setInterval(() => {
|
|
saveWatchProgress();
|
|
}, 5000);
|
|
|
|
setProgressSaveInterval(interval);
|
|
|
|
// Clean up interval on pause or unmount
|
|
return () => {
|
|
clearInterval(interval);
|
|
setProgressSaveInterval(null);
|
|
};
|
|
}
|
|
}, [id, type, paused, currentTime, duration]);
|
|
|
|
// Save progress one more time when component unmounts
|
|
useEffect(() => {
|
|
return () => {
|
|
if (id && type && duration > 0) {
|
|
saveWatchProgress();
|
|
}
|
|
};
|
|
}, [id, type, currentTime, duration]);
|
|
|
|
// Function to save watch progress
|
|
const saveWatchProgress = async () => {
|
|
if (id && type && currentTime > 0 && duration > 0) {
|
|
const progress = {
|
|
currentTime,
|
|
duration,
|
|
lastUpdated: Date.now()
|
|
};
|
|
|
|
try {
|
|
await storageService.setWatchProgress(id, type, progress, episodeId);
|
|
logger.log(`[VideoPlayer] Saved progress: ${currentTime.toFixed(1)}/${duration.toFixed(1)} (${((currentTime/duration)*100).toFixed(1)}%)`);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error saving watch progress:', error);
|
|
}
|
|
} else {
|
|
logger.log(`[VideoPlayer] Cannot save progress: id=${id}, type=${type}, currentTime=${currentTime}, duration=${duration}`);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
max.value = duration;
|
|
}, [duration]);
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
|
} else {
|
|
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
|
}
|
|
};
|
|
|
|
const onSliderValueChange = (value: number) => {
|
|
if (videoRef.current) {
|
|
const newTime = Math.floor(value);
|
|
videoRef.current.seek(newTime);
|
|
setCurrentTime(newTime);
|
|
progress.value = newTime;
|
|
}
|
|
};
|
|
|
|
const togglePlayback = () => {
|
|
setPaused(!paused);
|
|
};
|
|
|
|
const skip = (seconds: number) => {
|
|
if (videoRef.current) {
|
|
const newTime = Math.max(0, Math.min(currentTime + seconds, duration));
|
|
videoRef.current.seek(newTime);
|
|
setCurrentTime(newTime);
|
|
progress.value = newTime;
|
|
}
|
|
};
|
|
|
|
const onProgress = (data: { currentTime: number }) => {
|
|
setCurrentTime(data.currentTime);
|
|
progress.value = data.currentTime;
|
|
};
|
|
|
|
const onLoad = (data: { duration: number }) => {
|
|
setDuration(data.duration);
|
|
max.value = data.duration;
|
|
|
|
logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration}`);
|
|
|
|
// If we have an initial position to seek to, do it now
|
|
if (initialPosition !== null && !isInitialSeekComplete && videoRef.current) {
|
|
logger.log(`[VideoPlayer] Will seek to saved position: ${initialPosition}`);
|
|
|
|
// Seek immediately with a small delay
|
|
setTimeout(() => {
|
|
if (videoRef.current) {
|
|
try {
|
|
videoRef.current.seek(initialPosition);
|
|
setCurrentTime(initialPosition);
|
|
progress.value = initialPosition;
|
|
setIsInitialSeekComplete(true);
|
|
logger.log(`[VideoPlayer] Successfully seeked to saved position: ${initialPosition}`);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error seeking to saved position:', error);
|
|
}
|
|
} else {
|
|
logger.error('[VideoPlayer] videoRef is no longer valid when attempting to seek');
|
|
}
|
|
}, 1000); // Increase delay to ensure video is fully loaded
|
|
} else {
|
|
if (initialPosition === null) {
|
|
logger.log(`[VideoPlayer] No initial position to seek to`);
|
|
} else if (isInitialSeekComplete) {
|
|
logger.log(`[VideoPlayer] Initial seek already completed`);
|
|
} else {
|
|
logger.log(`[VideoPlayer] videoRef not available for seeking`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => {
|
|
const tracks = data.audioTracks || [];
|
|
setAudioTracks(tracks);
|
|
logger.log(`[VideoPlayer] Available audio tracks:`, tracks);
|
|
};
|
|
|
|
const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => {
|
|
const tracks = e.textTracks || [];
|
|
setTextTracks(tracks);
|
|
logger.log(`[VideoPlayer] Available subtitle tracks:`, tracks);
|
|
};
|
|
|
|
// Toggle through aspect ratio modes
|
|
const cycleAspectRatio = () => {
|
|
const currentIndex = resizeModes.indexOf(resizeMode);
|
|
const nextIndex = (currentIndex + 1) % resizeModes.length;
|
|
logger.log(`[VideoPlayer] Changing aspect ratio from ${resizeMode} to ${resizeModes[nextIndex]}`);
|
|
setResizeMode(resizeModes[nextIndex]);
|
|
};
|
|
|
|
// Function to enable immersive mode
|
|
const enableImmersiveMode = () => {
|
|
StatusBar.setHidden(true);
|
|
|
|
if (Platform.OS === 'android') {
|
|
// Full immersive mode - hides both status and navigation bars
|
|
// Use setBarMode with 'FullSticky' mode to hide all bars with sticky behavior
|
|
RNImmersiveMode.setBarMode('FullSticky');
|
|
|
|
// Alternative: if you want to use fullLayout method (which is in the TypeScript definition)
|
|
RNImmersiveMode.fullLayout(true);
|
|
}
|
|
};
|
|
|
|
// Function to disable immersive mode
|
|
const disableImmersiveMode = () => {
|
|
StatusBar.setHidden(false);
|
|
|
|
if (Platform.OS === 'android') {
|
|
// Restore normal mode using setBarMode
|
|
RNImmersiveMode.setBarMode('Normal');
|
|
|
|
// Alternative: disable fullLayout
|
|
RNImmersiveMode.fullLayout(false);
|
|
}
|
|
};
|
|
|
|
// Function to handle closing the video player
|
|
const handleClose = () => {
|
|
// First unlock the screen orientation
|
|
const unlockOrientation = async () => {
|
|
await ScreenOrientation.unlockAsync();
|
|
};
|
|
unlockOrientation();
|
|
|
|
// Disable immersive mode
|
|
disableImmersiveMode();
|
|
|
|
// Navigate back
|
|
navigation.goBack();
|
|
};
|
|
|
|
// Add debug logs for modal visibility
|
|
useEffect(() => {
|
|
if (showAudioModal) {
|
|
logger.log("[VideoPlayer] Audio modal should be visible now");
|
|
logger.log("[VideoPlayer] Available audio tracks:", audioTracks);
|
|
}
|
|
}, [showAudioModal, audioTracks]);
|
|
|
|
useEffect(() => {
|
|
if (showSubtitleModal) {
|
|
logger.log("[VideoPlayer] Subtitle modal should be visible now");
|
|
logger.log("[VideoPlayer] Available text tracks:", textTracks);
|
|
}
|
|
}, [showSubtitleModal, textTracks]);
|
|
|
|
// Attempt to seek once videoRef is available
|
|
useEffect(() => {
|
|
if (initialPosition !== null && !isInitialSeekComplete && videoRef.current) {
|
|
logger.log(`[VideoPlayer] videoRef is now available, attempting to seek to: ${initialPosition}`);
|
|
try {
|
|
videoRef.current.seek(initialPosition);
|
|
setCurrentTime(initialPosition);
|
|
progress.value = initialPosition;
|
|
setIsInitialSeekComplete(true);
|
|
logger.log(`[VideoPlayer] Successfully seeked to position: ${initialPosition}`);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error seeking to position on ref available:', error);
|
|
}
|
|
}
|
|
}, [videoRef.current, initialPosition, isInitialSeekComplete]);
|
|
|
|
// Load resume preference on mount
|
|
useEffect(() => {
|
|
const loadResumePreference = async () => {
|
|
try {
|
|
const pref = await AsyncStorage.getItem(RESUME_PREF_KEY);
|
|
if (pref) {
|
|
setResumePreference(pref);
|
|
logger.log(`[VideoPlayer] Loaded resume preference: ${pref}`);
|
|
|
|
// If user has a preference, apply it automatically
|
|
if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) {
|
|
setShowResumeOverlay(false);
|
|
setInitialPosition(resumePosition);
|
|
logger.log(`[VideoPlayer] Auto-resuming based on saved preference`);
|
|
} else if (pref === RESUME_PREF.ALWAYS_START_OVER) {
|
|
setShowResumeOverlay(false);
|
|
setInitialPosition(0);
|
|
logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error loading resume preference:', error);
|
|
}
|
|
};
|
|
|
|
loadResumePreference();
|
|
}, [resumePosition]);
|
|
|
|
// Reset resume preference
|
|
const resetResumePreference = async () => {
|
|
try {
|
|
await AsyncStorage.removeItem(RESUME_PREF_KEY);
|
|
setResumePreference(null);
|
|
logger.log(`[VideoPlayer] Reset resume preference`);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error resetting resume preference:', error);
|
|
}
|
|
};
|
|
|
|
// Handle resume from overlay - modified to save preference
|
|
const handleResume = async () => {
|
|
if (resumePosition !== null && videoRef.current) {
|
|
logger.log(`[VideoPlayer] Resuming from ${resumePosition}`);
|
|
|
|
// Save preference if remember choice is checked
|
|
if (rememberChoice) {
|
|
try {
|
|
await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME);
|
|
logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_RESUME}`);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error saving resume preference:', error);
|
|
}
|
|
}
|
|
|
|
// Set initial position to trigger seek
|
|
setInitialPosition(resumePosition);
|
|
// Hide overlay
|
|
setShowResumeOverlay(false);
|
|
}
|
|
};
|
|
|
|
// Handle start from beginning - modified to save preference
|
|
const handleStartFromBeginning = async () => {
|
|
logger.log(`[VideoPlayer] Starting from beginning`);
|
|
|
|
// Save preference if remember choice is checked
|
|
if (rememberChoice) {
|
|
try {
|
|
await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER);
|
|
logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_START_OVER}`);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error saving resume preference:', error);
|
|
}
|
|
}
|
|
|
|
// Hide overlay
|
|
setShowResumeOverlay(false);
|
|
// Set initial position to 0
|
|
setInitialPosition(0);
|
|
// Make sure we seek to beginning
|
|
if (videoRef.current) {
|
|
videoRef.current.seek(0);
|
|
setCurrentTime(0);
|
|
progress.value = 0;
|
|
}
|
|
};
|
|
|
|
// Update the showControls logic to include animation
|
|
const toggleControls = () => {
|
|
// Start fade animation
|
|
Animated.timing(fadeAnim, {
|
|
toValue: showControls ? 0 : 1,
|
|
duration: 300,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
// Update state
|
|
setShowControls(!showControls);
|
|
};
|
|
|
|
// Add onBuffer handler to Video component
|
|
const onBuffer = ({ isBuffering }: { isBuffering: boolean }) => {
|
|
// You can use this to show a loading indicator if needed
|
|
logger.log(`[VideoPlayer] Buffering: ${isBuffering}`);
|
|
};
|
|
|
|
// Add onProgress handler to track buffered data
|
|
const onLoadStart = () => {
|
|
setBuffered(0);
|
|
};
|
|
|
|
const handleProgress = (data: { currentTime: number, playableDuration: number, seekableDuration?: number }) => {
|
|
setCurrentTime(data.currentTime);
|
|
progress.value = data.currentTime;
|
|
|
|
// Ensure playableDuration is always at least equal to currentTime
|
|
const effectivePlayableDuration = Math.max(data.currentTime, data.playableDuration);
|
|
setBuffered(effectivePlayableDuration);
|
|
|
|
// Calculate buffer ahead (cannot be negative)
|
|
const bufferAhead = Math.max(0, effectivePlayableDuration - data.currentTime);
|
|
const bufferPercentage = ((effectivePlayableDuration / (duration || 1)) * 100);
|
|
|
|
// Add detailed buffer logging
|
|
logger.log(`[VideoPlayer] Buffer Status:
|
|
Current Time: ${data.currentTime.toFixed(2)}s
|
|
Playable Duration: ${effectivePlayableDuration.toFixed(2)}s
|
|
Buffered Ahead: ${bufferAhead.toFixed(2)}s
|
|
Seekable Duration: ${data.seekableDuration?.toFixed(2) || 'N/A'}s
|
|
Buffer Percentage: ${bufferPercentage.toFixed(1)}%
|
|
`);
|
|
};
|
|
|
|
// Add onError handler
|
|
const handleError = (error: any) => {
|
|
logger.error('[VideoPlayer] Playback Error:', error);
|
|
// Optionally, you could show an error message to the user here
|
|
};
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<TouchableOpacity
|
|
style={styles.videoContainer}
|
|
onPress={toggleControls}
|
|
activeOpacity={1}
|
|
>
|
|
<Video
|
|
ref={videoRef}
|
|
source={{ uri }}
|
|
style={styles.video}
|
|
paused={paused || showResumeOverlay}
|
|
resizeMode={resizeMode}
|
|
onLoad={onLoad}
|
|
onProgress={handleProgress}
|
|
rate={playbackSpeed}
|
|
progressUpdateInterval={250}
|
|
selectedAudioTrack={selectedAudioTrack !== null ?
|
|
{ type: 'index', value: selectedAudioTrack } as any :
|
|
undefined
|
|
}
|
|
onAudioTracks={onAudioTracks}
|
|
selectedTextTrack={selectedTextTrack as any}
|
|
onTextTracks={onTextTracks}
|
|
onBuffer={onBuffer}
|
|
onLoadStart={onLoadStart}
|
|
onError={handleError}
|
|
/>
|
|
|
|
{/* Slider Container with buffer indicator */}
|
|
<Animated.View style={[styles.sliderContainer, { opacity: fadeAnim }]}>
|
|
<View style={styles.sliderBackground}>
|
|
{/* Buffered Progress */}
|
|
<View style={[styles.bufferProgress, {
|
|
width: `${(buffered / (duration || 1)) * 100}%`
|
|
}]} />
|
|
</View>
|
|
<Slider
|
|
progress={progress}
|
|
minimumValue={min}
|
|
maximumValue={max}
|
|
style={styles.slider}
|
|
onValueChange={onSliderValueChange}
|
|
theme={{
|
|
minimumTrackTintColor: '#E50914',
|
|
maximumTrackTintColor: 'transparent',
|
|
bubbleBackgroundColor: '#E50914',
|
|
}}
|
|
/>
|
|
<View style={styles.timeDisplay}>
|
|
<Text style={styles.duration}>{formatTime(currentTime)}</Text>
|
|
<Text style={styles.duration}>{formatTime(duration)}</Text>
|
|
</View>
|
|
</Animated.View>
|
|
|
|
{/* Controls Overlay - Using Animated.View */}
|
|
<Animated.View style={[styles.controlsContainer, { opacity: fadeAnim }]}>
|
|
{/* Top Gradient & Header */}
|
|
<LinearGradient
|
|
colors={['rgba(0,0,0,0.7)', 'transparent']}
|
|
style={styles.topGradient}
|
|
>
|
|
<View style={styles.header}>
|
|
{/* Title Section - Enhanced with metadata */}
|
|
<View style={styles.titleSection}>
|
|
<Text style={styles.title}>{title}</Text>
|
|
{/* Show season and episode for series */}
|
|
{season && episode && (
|
|
<Text style={styles.episodeInfo}>
|
|
S{season}E{episode} {episodeTitle && `• ${episodeTitle}`}
|
|
</Text>
|
|
)}
|
|
{/* Show year, quality, and provider */}
|
|
<View style={styles.metadataRow}>
|
|
{year && <Text style={styles.metadataText}>{year}</Text>}
|
|
{quality && <View style={styles.qualityBadge}><Text style={styles.qualityText}>{quality}</Text></View>}
|
|
{streamProvider && <Text style={styles.providerText}>via {streamProvider}</Text>}
|
|
</View>
|
|
</View>
|
|
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
|
|
<Ionicons name="close" size={24} color="white" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</LinearGradient>
|
|
|
|
{/* Center Controls (Play/Pause, Skip) */}
|
|
<View style={styles.controls}>
|
|
<TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}>
|
|
<Ionicons name="play-back" size={24} color="white" />
|
|
<Text style={styles.skipText}>10</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={togglePlayback} style={styles.playButton}>
|
|
<Ionicons name={paused ? "play" : "pause"} size={40} color="white" />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}>
|
|
<Ionicons name="play-forward" size={24} color="white" />
|
|
<Text style={styles.skipText}>10</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Bottom Gradient */}
|
|
<LinearGradient
|
|
colors={['transparent', 'rgba(0,0,0,0.7)']}
|
|
style={styles.bottomGradient}
|
|
>
|
|
<View style={styles.bottomControls}>
|
|
{/* Bottom Buttons Row */}
|
|
<View style={styles.bottomButtons}>
|
|
{/* Speed Button */}
|
|
<TouchableOpacity style={styles.bottomButton}>
|
|
<Ionicons name="speedometer" size={20} color="white" />
|
|
<Text style={styles.bottomButtonText}>Speed ({playbackSpeed}x)</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Aspect Ratio Button - Added */}
|
|
<TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}>
|
|
<Ionicons name="resize" size={20} color="white" />
|
|
<Text style={styles.bottomButtonText}>
|
|
Aspect ({resizeMode})
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Audio Button - Updated language display */}
|
|
<TouchableOpacity
|
|
style={styles.bottomButton}
|
|
onPress={() => setShowAudioModal(true)}
|
|
disabled={audioTracks.length <= 1}
|
|
>
|
|
<Ionicons name="volume-high" size={20} color={audioTracks.length <= 1 ? 'grey' : 'white'} />
|
|
<Text style={[styles.bottomButtonText, audioTracks.length <= 1 && {color: 'grey'}]}>
|
|
{audioTracks.length > 0 && selectedAudioTrack !== null
|
|
? `Audio: ${formatLanguage(audioTracks.find(t => t.index === selectedAudioTrack)?.language)}`
|
|
: 'Audio: Default'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Subtitle Button - Updated language display */}
|
|
<TouchableOpacity
|
|
style={styles.bottomButton}
|
|
onPress={() => setShowSubtitleModal(true)}
|
|
disabled={textTracks.length === 0}
|
|
>
|
|
<Ionicons name="text" size={20} color={textTracks.length === 0 ? 'grey' : 'white'} />
|
|
<Text style={[styles.bottomButtonText, textTracks.length === 0 && {color: 'grey'}]}>
|
|
{selectedTextTrack?.type === 'disabled'
|
|
? 'Subtitles: Off'
|
|
: `Subtitles: ${formatLanguage(textTracks.find(t => t.index === selectedTextTrack?.value)?.language)}`}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</LinearGradient>
|
|
</Animated.View>
|
|
|
|
{/* Resume Overlay */}
|
|
{showResumeOverlay && resumePosition !== null && (
|
|
<View style={styles.resumeOverlay}>
|
|
<LinearGradient
|
|
colors={['rgba(0,0,0,0.9)', 'rgba(0,0,0,0.7)']}
|
|
style={styles.resumeContainer}
|
|
>
|
|
<View style={styles.resumeContent}>
|
|
<View style={styles.resumeIconContainer}>
|
|
<Ionicons name="play-circle" size={40} color="#E50914" />
|
|
</View>
|
|
<View style={styles.resumeTextContainer}>
|
|
<Text style={styles.resumeTitle}>Continue Watching</Text>
|
|
<Text style={styles.resumeInfo}>
|
|
{title}
|
|
{season && episode && ` • S${season}E${episode}`}
|
|
</Text>
|
|
<View style={styles.resumeProgressContainer}>
|
|
<View style={styles.resumeProgressBar}>
|
|
<View
|
|
style={[
|
|
styles.resumeProgressFill,
|
|
{ width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` }
|
|
]}
|
|
/>
|
|
</View>
|
|
<Text style={styles.resumeTimeText}>
|
|
{formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Remember choice checkbox */}
|
|
<TouchableOpacity
|
|
style={styles.rememberChoiceContainer}
|
|
onPress={() => setRememberChoice(!rememberChoice)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={styles.checkboxContainer}>
|
|
<View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}>
|
|
{rememberChoice && <Ionicons name="checkmark" size={12} color="white" />}
|
|
</View>
|
|
<Text style={styles.rememberChoiceText}>Remember my choice</Text>
|
|
</View>
|
|
|
|
{resumePreference && (
|
|
<TouchableOpacity
|
|
onPress={resetResumePreference}
|
|
style={styles.resetPreferenceButton}
|
|
>
|
|
<Text style={styles.resetPreferenceText}>Reset</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
<View style={styles.resumeButtons}>
|
|
<TouchableOpacity
|
|
style={styles.resumeButton}
|
|
onPress={handleStartFromBeginning}
|
|
>
|
|
<Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} />
|
|
<Text style={styles.resumeButtonText}>Start Over</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.resumeButton, styles.resumeFromButton]}
|
|
onPress={handleResume}
|
|
>
|
|
<Ionicons name="play" size={16} color="white" style={styles.buttonIcon} />
|
|
<Text style={styles.resumeButtonText}>Resume</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</LinearGradient>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
{/* Audio Selection Modal - Updated language display */}
|
|
{showAudioModal && (
|
|
<View style={styles.fullscreenOverlay}>
|
|
<View style={styles.enhancedModalContainer}>
|
|
<View style={styles.enhancedModalHeader}>
|
|
<Text style={styles.enhancedModalTitle}>Audio</Text>
|
|
<TouchableOpacity
|
|
style={styles.enhancedCloseButton}
|
|
onPress={() => setShowAudioModal(false)}
|
|
>
|
|
<Ionicons name="close" size={24} color="white" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView style={styles.trackListScrollContainer}>
|
|
<View style={styles.trackListContainer}>
|
|
{audioTracks.length > 0 ? audioTracks.map(track => (
|
|
<TouchableOpacity
|
|
key={track.index}
|
|
style={styles.enhancedTrackItem}
|
|
onPress={() => {
|
|
setSelectedAudioTrack(track.index);
|
|
setShowAudioModal(false);
|
|
}}
|
|
>
|
|
<View style={styles.trackInfoContainer}>
|
|
<Text style={styles.trackPrimaryText}>
|
|
{formatLanguage(track.language) || track.title || `Track ${track.index + 1}`}
|
|
</Text>
|
|
{(track.title && track.language) && (
|
|
<Text style={styles.trackSecondaryText}>{track.title}</Text>
|
|
)}
|
|
{track.type && <Text style={styles.trackSecondaryText}>{track.type}</Text>}
|
|
</View>
|
|
{selectedAudioTrack === track.index && (
|
|
<View style={styles.selectedIndicatorContainer}>
|
|
<Ionicons name="checkmark" size={22} color="#E50914" />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
)) : (
|
|
<View style={styles.emptyStateContainer}>
|
|
<Ionicons name="alert-circle-outline" size={40} color="#888" />
|
|
<Text style={styles.emptyStateText}>No audio tracks available</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Subtitle Selection Modal - Updated language display */}
|
|
{showSubtitleModal && (
|
|
<View style={styles.fullscreenOverlay}>
|
|
<View style={styles.enhancedModalContainer}>
|
|
<View style={styles.enhancedModalHeader}>
|
|
<Text style={styles.enhancedModalTitle}>Subtitles</Text>
|
|
<TouchableOpacity
|
|
style={styles.enhancedCloseButton}
|
|
onPress={() => setShowSubtitleModal(false)}
|
|
>
|
|
<Ionicons name="close" size={24} color="white" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView style={styles.trackListScrollContainer}>
|
|
<View style={styles.trackListContainer}>
|
|
{/* Off option with improved design */}
|
|
<TouchableOpacity
|
|
style={styles.enhancedTrackItem}
|
|
onPress={() => {
|
|
setSelectedTextTrack({ type: 'disabled' });
|
|
setShowSubtitleModal(false);
|
|
}}
|
|
>
|
|
<View style={styles.trackInfoContainer}>
|
|
<Text style={styles.trackPrimaryText}>Off</Text>
|
|
</View>
|
|
{selectedTextTrack?.type === 'disabled' && (
|
|
<View style={styles.selectedIndicatorContainer}>
|
|
<Ionicons name="checkmark" size={22} color="#E50914" />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
{/* Available subtitle tracks with improved design */}
|
|
{textTracks.length > 0 ? textTracks.map(track => (
|
|
<TouchableOpacity
|
|
key={track.index}
|
|
style={styles.enhancedTrackItem}
|
|
onPress={() => {
|
|
setSelectedTextTrack({ type: 'index', value: track.index });
|
|
setShowSubtitleModal(false);
|
|
}}
|
|
>
|
|
<View style={styles.trackInfoContainer}>
|
|
<Text style={styles.trackPrimaryText}>
|
|
{formatLanguage(track.language) || track.title || `Subtitle ${track.index + 1}`}
|
|
</Text>
|
|
{(track.title && track.language) && (
|
|
<Text style={styles.trackSecondaryText}>{track.title}</Text>
|
|
)}
|
|
{track.type && <Text style={styles.trackSecondaryText}>{track.type}</Text>}
|
|
</View>
|
|
{selectedTextTrack?.type === 'index' &&
|
|
selectedTextTrack?.value === track.index && (
|
|
<View style={styles.selectedIndicatorContainer}>
|
|
<Ionicons name="checkmark" size={22} color="#E50914" />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
)) : (
|
|
<View style={styles.emptyStateContainer}>
|
|
<Ionicons name="alert-circle-outline" size={40} color="#888" />
|
|
<Text style={styles.emptyStateText}>No subtitle tracks available</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#000',
|
|
},
|
|
videoContainer: {
|
|
flex: 1,
|
|
},
|
|
video: {
|
|
flex: 1,
|
|
},
|
|
controlsContainer: {
|
|
...StyleSheet.absoluteFillObject,
|
|
justifyContent: 'space-between',
|
|
},
|
|
topGradient: {
|
|
paddingTop: Platform.OS === 'ios' ? 50 : 20, // Adjust top padding for safe area
|
|
paddingHorizontal: 20,
|
|
paddingBottom: 10, // Add some padding at the bottom of the gradient
|
|
},
|
|
bottomGradient: {
|
|
paddingBottom: Platform.OS === 'ios' ? 30 : 20,
|
|
paddingHorizontal: 20,
|
|
paddingTop: 20,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'flex-start', // Align items to the top
|
|
},
|
|
// Styles for the title section and metadata
|
|
titleSection: {
|
|
flex: 1, // Allow title section to take available space
|
|
marginRight: 10, // Add margin to avoid overlap with close button
|
|
},
|
|
title: {
|
|
color: 'white',
|
|
fontSize: 18,
|
|
fontWeight: 'bold',
|
|
},
|
|
episodeInfo: {
|
|
color: 'rgba(255, 255, 255, 0.9)',
|
|
fontSize: 14,
|
|
marginTop: 3,
|
|
},
|
|
metadataRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginTop: 5,
|
|
flexWrap: 'wrap', // Allow items to wrap if needed
|
|
},
|
|
metadataText: {
|
|
color: 'rgba(255, 255, 255, 0.7)',
|
|
fontSize: 12,
|
|
marginRight: 8,
|
|
},
|
|
qualityBadge: {
|
|
backgroundColor: '#E50914',
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 2,
|
|
borderRadius: 4,
|
|
marginRight: 8,
|
|
},
|
|
qualityText: {
|
|
color: 'white',
|
|
fontSize: 10,
|
|
fontWeight: 'bold',
|
|
},
|
|
providerText: {
|
|
color: 'rgba(255, 255, 255, 0.7)',
|
|
fontSize: 12,
|
|
fontStyle: 'italic', // Italicize provider text
|
|
},
|
|
closeButton: {
|
|
padding: 8,
|
|
},
|
|
controls: {
|
|
position: 'absolute',
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
gap: 40,
|
|
left: 0,
|
|
right: 0,
|
|
top: '50%',
|
|
transform: [{ translateY: -30 }], // Half the height of play button to center it perfectly
|
|
zIndex: 1000,
|
|
},
|
|
playButton: {
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 10,
|
|
},
|
|
skipButton: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
skipText: {
|
|
color: 'white',
|
|
fontSize: 12,
|
|
marginTop: 2,
|
|
},
|
|
bottomControls: {
|
|
gap: 12,
|
|
},
|
|
sliderContainer: {
|
|
position: 'absolute',
|
|
bottom: 55, // Moved closer to bottom buttons
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: 20,
|
|
zIndex: 1000,
|
|
},
|
|
sliderBackground: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
height: 3,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
|
borderRadius: 1.5,
|
|
overflow: 'hidden',
|
|
marginHorizontal: 20,
|
|
top: 13.5, // Center with the slider thumb
|
|
},
|
|
bufferProgress: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
|
},
|
|
slider: {
|
|
width: '100%',
|
|
height: 30,
|
|
zIndex: 1,
|
|
},
|
|
timeDisplay: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
width: '100%',
|
|
paddingHorizontal: 4,
|
|
marginTop: -4, // Reduced space between slider and time
|
|
marginBottom: 8, // Added space between time and buttons
|
|
},
|
|
duration: {
|
|
color: 'white',
|
|
fontSize: 12,
|
|
fontWeight: '500',
|
|
},
|
|
bottomButtons: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-around',
|
|
alignItems: 'center',
|
|
},
|
|
bottomButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 5,
|
|
},
|
|
bottomButtonText: {
|
|
color: 'white',
|
|
fontSize: 12,
|
|
},
|
|
modalOverlay: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
},
|
|
modalContent: {
|
|
width: '80%',
|
|
maxHeight: '70%',
|
|
backgroundColor: '#222',
|
|
borderRadius: 10,
|
|
overflow: 'hidden',
|
|
zIndex: 1000,
|
|
elevation: 5,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.8,
|
|
shadowRadius: 5,
|
|
},
|
|
modalHeader: {
|
|
padding: 16,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#333',
|
|
},
|
|
modalTitle: {
|
|
color: 'white',
|
|
fontSize: 18,
|
|
fontWeight: 'bold',
|
|
},
|
|
trackList: {
|
|
padding: 10,
|
|
},
|
|
trackItem: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: 15,
|
|
borderRadius: 5,
|
|
marginVertical: 5,
|
|
},
|
|
selectedTrackItem: {
|
|
backgroundColor: 'rgba(229, 9, 20, 0.2)',
|
|
},
|
|
trackLabel: {
|
|
color: 'white',
|
|
fontSize: 16,
|
|
},
|
|
noTracksText: {
|
|
color: 'white',
|
|
fontSize: 16,
|
|
textAlign: 'center',
|
|
padding: 20,
|
|
},
|
|
// New simplified modal styles
|
|
fullscreenOverlay: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: 'rgba(0,0,0,0.85)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
zIndex: 2000,
|
|
},
|
|
enhancedModalContainer: {
|
|
width: 300,
|
|
maxHeight: '70%',
|
|
backgroundColor: '#181818',
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 6 },
|
|
shadowOpacity: 0.4,
|
|
shadowRadius: 10,
|
|
elevation: 8,
|
|
},
|
|
enhancedModalHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#333',
|
|
},
|
|
enhancedModalTitle: {
|
|
color: 'white',
|
|
fontSize: 18,
|
|
fontWeight: 'bold',
|
|
},
|
|
enhancedCloseButton: {
|
|
padding: 4,
|
|
},
|
|
trackListScrollContainer: {
|
|
maxHeight: 350,
|
|
},
|
|
trackListContainer: {
|
|
padding: 6,
|
|
},
|
|
enhancedTrackItem: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: 10,
|
|
marginVertical: 2,
|
|
borderRadius: 6,
|
|
backgroundColor: '#222',
|
|
},
|
|
trackInfoContainer: {
|
|
flex: 1,
|
|
marginRight: 8,
|
|
},
|
|
trackPrimaryText: {
|
|
color: 'white',
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
trackSecondaryText: {
|
|
color: '#aaa',
|
|
fontSize: 11,
|
|
marginTop: 2,
|
|
},
|
|
selectedIndicatorContainer: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
backgroundColor: 'rgba(229, 9, 20, 0.15)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
emptyStateContainer: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: 20,
|
|
},
|
|
emptyStateText: {
|
|
color: '#888',
|
|
fontSize: 14,
|
|
marginTop: 8,
|
|
textAlign: 'center',
|
|
},
|
|
// Resume overlay styles
|
|
resumeOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
zIndex: 1000,
|
|
},
|
|
resumeContainer: {
|
|
width: '80%',
|
|
maxWidth: 500,
|
|
borderRadius: 12,
|
|
padding: 20,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 6,
|
|
elevation: 8,
|
|
},
|
|
resumeContent: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginBottom: 20,
|
|
},
|
|
resumeIconContainer: {
|
|
marginRight: 16,
|
|
width: 50,
|
|
height: 50,
|
|
borderRadius: 25,
|
|
backgroundColor: 'rgba(229, 9, 20, 0.2)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
resumeTextContainer: {
|
|
flex: 1,
|
|
},
|
|
resumeTitle: {
|
|
color: 'white',
|
|
fontSize: 20,
|
|
fontWeight: 'bold',
|
|
marginBottom: 4,
|
|
},
|
|
resumeInfo: {
|
|
color: 'rgba(255, 255, 255, 0.9)',
|
|
fontSize: 14,
|
|
},
|
|
resumeProgressContainer: {
|
|
marginTop: 12,
|
|
},
|
|
resumeProgressBar: {
|
|
height: 4,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
|
borderRadius: 2,
|
|
overflow: 'hidden',
|
|
marginBottom: 6,
|
|
},
|
|
resumeProgressFill: {
|
|
height: '100%',
|
|
backgroundColor: '#E50914',
|
|
},
|
|
resumeTimeText: {
|
|
color: 'rgba(255,255,255,0.7)',
|
|
fontSize: 12,
|
|
},
|
|
resumeButtons: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
width: '100%',
|
|
gap: 12,
|
|
},
|
|
resumeButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: 10,
|
|
paddingHorizontal: 16,
|
|
borderRadius: 6,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
|
minWidth: 110,
|
|
justifyContent: 'center',
|
|
},
|
|
buttonIcon: {
|
|
marginRight: 6,
|
|
},
|
|
resumeButtonText: {
|
|
color: 'white',
|
|
fontWeight: 'bold',
|
|
fontSize: 14,
|
|
},
|
|
resumeFromButton: {
|
|
backgroundColor: '#E50914',
|
|
},
|
|
rememberChoiceContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 16,
|
|
paddingHorizontal: 2,
|
|
},
|
|
checkboxContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
checkbox: {
|
|
width: 18,
|
|
height: 18,
|
|
borderRadius: 3,
|
|
borderWidth: 2,
|
|
borderColor: 'rgba(255, 255, 255, 0.5)',
|
|
marginRight: 8,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
checkboxChecked: {
|
|
backgroundColor: '#E50914',
|
|
borderColor: '#E50914',
|
|
},
|
|
rememberChoiceText: {
|
|
color: 'rgba(255, 255, 255, 0.8)',
|
|
fontSize: 14,
|
|
},
|
|
resetPreferenceButton: {
|
|
padding: 4,
|
|
},
|
|
resetPreferenceText: {
|
|
color: '#E50914',
|
|
fontSize: 12,
|
|
fontWeight: 'bold',
|
|
},
|
|
});
|
|
|
|
export default VideoPlayer;
|