mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
3439 lines
No EOL
132 KiB
TypeScript
3439 lines
No EOL
132 KiB
TypeScript
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
|
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
|
|
import FastImage from '@d11/react-native-fast-image';
|
|
import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator';
|
|
import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler';
|
|
import RNImmersiveMode from 'react-native-immersive-mode';
|
|
import * as ScreenOrientation from 'expo-screen-orientation';
|
|
import { storageService } from '../../services/storageService';
|
|
import { logger } from '../../utils/logger';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { MaterialIcons } from '@expo/vector-icons';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import Slider from '@react-native-community/slider';
|
|
import KSPlayerComponent, { KSPlayerRef, KSPlayerSource } from './KSPlayerComponent';
|
|
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
|
|
import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings';
|
|
import { useMetadata } from '../../hooks/useMetadata';
|
|
import { useSettings } from '../../hooks/useSettings';
|
|
|
|
import {
|
|
DEFAULT_SUBTITLE_SIZE,
|
|
AudioTrack,
|
|
TextTrack,
|
|
ResizeModeType,
|
|
WyzieSubtitle,
|
|
SubtitleCue,
|
|
RESUME_PREF_KEY,
|
|
RESUME_PREF,
|
|
SUBTITLE_SIZE_KEY
|
|
} from './utils/playerTypes';
|
|
import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils';
|
|
import { styles } from './utils/playerStyles';
|
|
import { SubtitleModals } from './modals/SubtitleModals';
|
|
import { AudioTrackModal } from './modals/AudioTrackModal';
|
|
// Removed ResumeOverlay usage when alwaysResume is enabled
|
|
import PlayerControls from './controls/PlayerControls';
|
|
import CustomSubtitles from './subtitles/CustomSubtitles';
|
|
import { SourcesModal } from './modals/SourcesModal';
|
|
import axios from 'axios';
|
|
import { stremioService } from '../../services/stremioService';
|
|
import * as Brightness from 'expo-brightness';
|
|
|
|
const KSPlayerCore: React.FC = () => {
|
|
const insets = useSafeAreaInsets();
|
|
const route = useRoute<RouteProp<RootStackParamList, 'PlayerIOS'>>();
|
|
const { uri, headers, streamProvider } = route.params as any;
|
|
|
|
const navigation = useNavigation<RootStackNavigationProp>();
|
|
|
|
// KSPlayer is active only on iOS for MKV streams
|
|
const isKsPlayerActive = Platform.OS === 'ios';
|
|
|
|
const {
|
|
title = 'Episode Name',
|
|
season,
|
|
episode,
|
|
episodeTitle,
|
|
quality,
|
|
year,
|
|
streamName,
|
|
id,
|
|
type,
|
|
episodeId,
|
|
imdbId,
|
|
availableStreams: passedAvailableStreams,
|
|
backdrop
|
|
} = route.params;
|
|
|
|
// Initialize Trakt autosync
|
|
const traktAutosync = useTraktAutosync({
|
|
id: id || '',
|
|
type: type === 'series' ? 'series' : 'movie',
|
|
title: episodeTitle || title,
|
|
year: year || 0,
|
|
imdbId: imdbId || '',
|
|
season: season,
|
|
episode: episode,
|
|
showTitle: title,
|
|
showYear: year,
|
|
showImdbId: imdbId,
|
|
episodeId: episodeId
|
|
});
|
|
|
|
// App settings
|
|
const { settings: appSettings } = useSettings();
|
|
|
|
safeDebugLog("Component mounted with props", {
|
|
uri, title, season, episode, episodeTitle, quality, year,
|
|
streamProvider, id, type, episodeId, imdbId
|
|
});
|
|
|
|
const screenData = Dimensions.get('screen');
|
|
const [screenDimensions, setScreenDimensions] = useState(screenData);
|
|
|
|
// iPad-specific fullscreen handling
|
|
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
|
|
const shouldUseFullscreen = isIPad;
|
|
|
|
// Use window dimensions for iPad instead of screen dimensions
|
|
const windowData = Dimensions.get('window');
|
|
const effectiveDimensions = shouldUseFullscreen ? windowData : screenData;
|
|
|
|
const [paused, setPaused] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [showControls, setShowControls] = useState(true);
|
|
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
|
|
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
|
|
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
|
|
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
|
|
const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch');
|
|
const [buffered, setBuffered] = useState(0);
|
|
const [seekPosition, setSeekPosition] = useState<number | null>(null);
|
|
const ksPlayerRef = useRef<KSPlayerRef>(null);
|
|
const [showAudioModal, setShowAudioModal] = useState(false);
|
|
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
|
|
const [initialPosition, setInitialPosition] = useState<number | null>(null);
|
|
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null);
|
|
const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
|
|
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
|
|
const [resumePosition, setResumePosition] = useState<number | null>(null);
|
|
const [savedDuration, setSavedDuration] = useState<number | null>(null);
|
|
const initialSeekTargetRef = useRef<number | null>(null);
|
|
const initialSeekVerifiedRef = useRef(false);
|
|
const isSourceSeekableRef = useRef<boolean | null>(null);
|
|
const fadeAnim = useRef(new Animated.Value(1)).current;
|
|
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
|
|
const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false);
|
|
const DISABLE_OPENING_OVERLAY = false; // Enable opening overlay animation
|
|
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 [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
|
|
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
|
|
|
|
const [isBuffering, setIsBuffering] = useState(false);
|
|
const [ksAudioTracks, setKsAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
|
const [ksTextTracks, setKsTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
|
const [isPlayerReady, setIsPlayerReady] = useState(false);
|
|
// Removed progressAnim and progressBarRef - no longer needed with React Native Community Slider
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const isSeeking = useRef(false);
|
|
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
|
|
const pendingSeekValue = useRef<number | null>(null);
|
|
const lastSeekTime = useRef<number>(0);
|
|
const wasPlayingBeforeDragRef = useRef<boolean>(false);
|
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
|
|
const [is16by9Content, setIs16by9Content] = useState(false);
|
|
const [customVideoStyles, setCustomVideoStyles] = useState<any>({});
|
|
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);
|
|
const pinchRef = useRef<PinchGestureHandler>(null);
|
|
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
|
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
|
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
|
|
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
|
|
// External subtitle customization
|
|
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);
|
|
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
|
|
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
|
|
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
|
|
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false);
|
|
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
|
|
const [showSourcesModal, setShowSourcesModal] = useState<boolean>(false);
|
|
const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {});
|
|
// Playback speed controls required by PlayerControls
|
|
const speedOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
|
|
const [playbackSpeed, setPlaybackSpeed] = useState<number>(1.0);
|
|
const cyclePlaybackSpeed = useCallback(() => {
|
|
const idx = speedOptions.indexOf(playbackSpeed);
|
|
const nextIdx = (idx + 1) % speedOptions.length;
|
|
setPlaybackSpeed(speedOptions[nextIdx]);
|
|
}, [playbackSpeed, speedOptions]);
|
|
// Smart URL processing for KSPlayer compatibility
|
|
const processUrlForKsPlayer = (url: string): string => {
|
|
try {
|
|
// Validate URL first
|
|
const urlObj = new URL(url);
|
|
|
|
// Only decode if the URL appears to be double-encoded
|
|
// Be more conservative - only check for clear double-encoding indicators
|
|
|
|
// Check 1: %25 indicates double-encoded % character
|
|
const hasDoubleEncodedPercent = url.includes('%25');
|
|
|
|
// Check 2: Only flag %2F + // if encoded slashes appear in the path/domain part
|
|
// (not just in query params where they might be legitimate base64/etc)
|
|
const hasProblematicEncodedSlashes = (() => {
|
|
const beforeQuery = url.split('?')[0]; // Get URL before query params
|
|
return beforeQuery.includes('%2F') && beforeQuery.includes('//');
|
|
})();
|
|
|
|
// Check 3: Only flag %3A + :// if colons are encoded in the scheme
|
|
const hasProblematicEncodedColons = (() => {
|
|
const schemeEnd = url.indexOf('://');
|
|
if (schemeEnd === -1) return false;
|
|
const schemePart = url.substring(0, schemeEnd);
|
|
return schemePart.includes('%3A');
|
|
})();
|
|
|
|
const hasDoubleEncoding = hasDoubleEncodedPercent ||
|
|
hasProblematicEncodedSlashes ||
|
|
hasProblematicEncodedColons;
|
|
|
|
if (hasDoubleEncoding) {
|
|
logger.log('[VideoPlayer] Detected double-encoded URL, decoding once');
|
|
return decodeURIComponent(url);
|
|
}
|
|
|
|
// For URLs with special characters in query params, ensure proper encoding
|
|
if (urlObj.search) {
|
|
const searchParams = new URLSearchParams(urlObj.search);
|
|
urlObj.search = searchParams.toString();
|
|
return urlObj.toString();
|
|
}
|
|
|
|
return url;
|
|
} catch (e) {
|
|
logger.warn('[VideoPlayer] URL processing failed, using original:', e);
|
|
return url;
|
|
}
|
|
};
|
|
|
|
const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(processUrlForKsPlayer(uri));
|
|
const [isChangingSource, setIsChangingSource] = useState<boolean>(false);
|
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
|
const [errorDetails, setErrorDetails] = useState<string>('');
|
|
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const [pendingSeek, setPendingSeek] = useState<{ position: number; shouldPlay: boolean } | null>(null);
|
|
const [currentQuality, setCurrentQuality] = useState<string | undefined>(quality);
|
|
const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider);
|
|
const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName);
|
|
const [lastAudioTrackCheck, setLastAudioTrackCheck] = useState<number>(0);
|
|
const [audioTrackFallbackAttempts, setAudioTrackFallbackAttempts] = useState<number>(0);
|
|
const isMounted = useRef(true);
|
|
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
|
|
|
|
// Silent startup-timeout retry state
|
|
const startupRetryCountRef = useRef(0);
|
|
const startupRetryTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const MAX_STARTUP_RETRIES = 3;
|
|
|
|
// Pause overlay state
|
|
const [showPauseOverlay, setShowPauseOverlay] = useState(false);
|
|
const pauseOverlayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const pauseOverlayOpacity = useRef(new Animated.Value(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;
|
|
|
|
// Next episode button state
|
|
const [showNextEpisodeButton, setShowNextEpisodeButton] = useState(false);
|
|
const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false);
|
|
const [nextLoadingProvider, setNextLoadingProvider] = useState<string | null>(null);
|
|
const [nextLoadingQuality, setNextLoadingQuality] = useState<string | null>(null);
|
|
const [nextLoadingTitle, setNextLoadingTitle] = useState<string | null>(null);
|
|
const nextEpisodeButtonOpacity = useRef(new Animated.Value(0)).current;
|
|
const nextEpisodeButtonScale = useRef(new Animated.Value(0.8)).current;
|
|
|
|
// Cast display 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;
|
|
|
|
// Volume and brightness controls
|
|
const [volume, setVolume] = useState(100); // KSPlayer uses 0-100 range
|
|
const [brightness, setBrightness] = useState(1.0);
|
|
const [showVolumeOverlay, setShowVolumeOverlay] = useState(false);
|
|
const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false);
|
|
const [subtitleSettingsLoaded, setSubtitleSettingsLoaded] = useState(false);
|
|
const volumeOverlayOpacity = useRef(new Animated.Value(0)).current;
|
|
const brightnessOverlayOpacity = useRef(new Animated.Value(0)).current;
|
|
const volumeOverlayTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
const brightnessOverlayTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
const lastVolumeChange = useRef<number>(0);
|
|
const lastBrightnessChange = useRef<number>(0);
|
|
|
|
// Get metadata to access logo (only if we have a valid id)
|
|
const shouldLoadMetadata = Boolean(id && type);
|
|
const metadataResult = useMetadata({
|
|
id: id || 'placeholder',
|
|
type: type || 'movie'
|
|
});
|
|
const { metadata, loading: metadataLoading, groupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} };
|
|
const { settings } = useSettings();
|
|
|
|
// Logo animation values
|
|
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;
|
|
|
|
// Check if we have a logo to show
|
|
const hasLogo = metadata && metadata.logo && !metadataLoading;
|
|
|
|
// Load custom backdrop on mount
|
|
// Prefetch backdrop and title logo for faster loading screen appearance
|
|
useEffect(() => {
|
|
if (backdrop && typeof backdrop === 'string') {
|
|
// Reset loading state
|
|
setIsBackdropLoaded(false);
|
|
backdropImageOpacityAnim.setValue(0);
|
|
|
|
// Prefetch the image
|
|
try {
|
|
FastImage.preload([{ uri: backdrop }]);
|
|
// Image prefetch initiated, fade it in smoothly
|
|
setIsBackdropLoaded(true);
|
|
Animated.timing(backdropImageOpacityAnim, {
|
|
toValue: 1,
|
|
duration: 400,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
} catch (error) {
|
|
// If prefetch fails, still show the image but without animation
|
|
if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error);
|
|
setIsBackdropLoaded(true);
|
|
backdropImageOpacityAnim.setValue(1);
|
|
}
|
|
} else {
|
|
// No backdrop provided, consider it "loaded"
|
|
setIsBackdropLoaded(true);
|
|
backdropImageOpacityAnim.setValue(0);
|
|
}
|
|
}, [backdrop]);
|
|
|
|
useEffect(() => {
|
|
const logoUrl = (metadata && (metadata as any).logo) as string | undefined;
|
|
if (logoUrl && typeof logoUrl === 'string') {
|
|
try {
|
|
FastImage.preload([{ uri: logoUrl }]);
|
|
} catch (error) {
|
|
// Silently ignore logo prefetch errors
|
|
}
|
|
}
|
|
}, [metadata]);
|
|
// Resolve current episode description for series
|
|
const currentEpisodeDescription = (() => {
|
|
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 '';
|
|
}
|
|
})();
|
|
|
|
// Find next episode for series
|
|
const nextEpisode = useMemo(() => {
|
|
try {
|
|
if (type !== 'series' || !season || !episode) return null;
|
|
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
|
|
if (!allEpisodes || allEpisodes.length === 0) return null;
|
|
|
|
// First try 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]);
|
|
|
|
// Small offset (in seconds) used to avoid seeking to the *exact* end of the
|
|
// file which triggers the `onEnd` callback and causes playback to restart.
|
|
const END_EPSILON = 0.3;
|
|
|
|
const hideControls = () => {
|
|
// Do not hide while user is interacting with the slider
|
|
if (isDragging) {
|
|
return;
|
|
}
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 0,
|
|
duration: 300,
|
|
useNativeDriver: true,
|
|
}).start(() => setShowControls(false));
|
|
};
|
|
|
|
const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => {
|
|
return {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: screenWidth,
|
|
height: screenHeight,
|
|
};
|
|
};
|
|
|
|
const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
|
|
const { scale } = event.nativeEvent;
|
|
const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
|
|
setZoomScale(newScale);
|
|
if (DEBUG_MODE) {
|
|
if (__DEV__) logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`);
|
|
}
|
|
};
|
|
|
|
const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
|
|
if (event.nativeEvent.state === State.END) {
|
|
setLastZoomScale(zoomScale);
|
|
if (DEBUG_MODE) {
|
|
if (__DEV__) logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const resetZoom = () => {
|
|
const targetZoom = is16by9Content ? 1.1 : 1;
|
|
setZoomScale(targetZoom);
|
|
setLastZoomScale(targetZoom);
|
|
if (DEBUG_MODE) {
|
|
if (__DEV__) logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`);
|
|
}
|
|
};
|
|
|
|
// Volume gesture handler (right side of screen)
|
|
const onVolumeGestureEvent = async (event: PanGestureHandlerGestureEvent) => {
|
|
const { translationY, state } = event.nativeEvent;
|
|
const sensitivity = 0.050; // Higher sensitivity for volume (more responsive than brightness)
|
|
|
|
if (state === State.ACTIVE) {
|
|
const deltaY = -translationY; // Invert for natural feel (up = increase)
|
|
const volumeChange = deltaY * sensitivity;
|
|
const newVolume = Math.max(0, Math.min(100, volume + volumeChange));
|
|
|
|
if (Math.abs(newVolume - volume) > 0.05) { // Even lower threshold for volume responsiveness
|
|
setVolume(newVolume);
|
|
lastVolumeChange.current = Date.now();
|
|
|
|
// Show overlay with smoother animation
|
|
if (!showVolumeOverlay) {
|
|
setShowVolumeOverlay(true);
|
|
Animated.spring(volumeOverlayOpacity, {
|
|
toValue: 1,
|
|
tension: 100,
|
|
friction: 8,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
}
|
|
|
|
// Clear existing timeout
|
|
if (volumeOverlayTimeout.current) {
|
|
clearTimeout(volumeOverlayTimeout.current);
|
|
}
|
|
|
|
// Hide overlay after 1.5 seconds
|
|
volumeOverlayTimeout.current = setTimeout(() => {
|
|
Animated.timing(volumeOverlayOpacity, {
|
|
toValue: 0,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}).start(() => {
|
|
setShowVolumeOverlay(false);
|
|
});
|
|
}, 1500);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Brightness gesture handler (left side of screen)
|
|
const onBrightnessGestureEvent = async (event: PanGestureHandlerGestureEvent) => {
|
|
const { translationY, state } = event.nativeEvent;
|
|
const sensitivity = 0.001; // Lower sensitivity for finer brightness control
|
|
|
|
if (state === State.ACTIVE) {
|
|
const deltaY = -translationY; // Invert for natural feel (up = increase)
|
|
const brightnessChange = deltaY * sensitivity;
|
|
const newBrightness = Math.max(0, Math.min(1, brightness + brightnessChange));
|
|
|
|
if (Math.abs(newBrightness - brightness) > 0.001) { // Much lower threshold for more responsive updates
|
|
setBrightness(newBrightness);
|
|
lastBrightnessChange.current = Date.now();
|
|
|
|
// Set device brightness using Expo Brightness
|
|
try {
|
|
await Brightness.setBrightnessAsync(newBrightness);
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Device brightness set to: ${newBrightness}`);
|
|
}
|
|
} catch (error) {
|
|
logger.warn('[VideoPlayer] Error setting device brightness:', error);
|
|
}
|
|
|
|
// Show overlay with smoother animation
|
|
if (!showBrightnessOverlay) {
|
|
setShowBrightnessOverlay(true);
|
|
Animated.spring(brightnessOverlayOpacity, {
|
|
toValue: 1,
|
|
tension: 100,
|
|
friction: 8,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
}
|
|
|
|
// Clear existing timeout
|
|
if (brightnessOverlayTimeout.current) {
|
|
clearTimeout(brightnessOverlayTimeout.current);
|
|
}
|
|
|
|
// Hide overlay after 1.5 seconds (reduced from 2 seconds)
|
|
brightnessOverlayTimeout.current = setTimeout(() => {
|
|
Animated.timing(brightnessOverlayOpacity, {
|
|
toValue: 0,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}).start(() => {
|
|
setShowBrightnessOverlay(false);
|
|
});
|
|
}, 1500);
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (videoAspectRatio && effectiveDimensions.width > 0 && effectiveDimensions.height > 0) {
|
|
const styles = calculateVideoStyles(
|
|
videoAspectRatio * 1000,
|
|
1000,
|
|
effectiveDimensions.width,
|
|
effectiveDimensions.height
|
|
);
|
|
setCustomVideoStyles(styles);
|
|
if (DEBUG_MODE) {
|
|
if (__DEV__) logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles);
|
|
}
|
|
}
|
|
}, [effectiveDimensions, videoAspectRatio]);
|
|
|
|
// Force landscape orientation after opening animation completes
|
|
useEffect(() => {
|
|
const lockOrientation = async () => {
|
|
try {
|
|
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
|
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
|
|
} catch (error) {
|
|
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
|
|
}
|
|
};
|
|
|
|
// Lock orientation after opening animation completes to prevent glitches
|
|
if (isOpeningAnimationComplete) {
|
|
lockOrientation();
|
|
}
|
|
|
|
return () => {
|
|
// Do not unlock orientation here; we unlock explicitly on close to avoid mid-transition flips
|
|
};
|
|
}, [isOpeningAnimationComplete]);
|
|
|
|
useEffect(() => {
|
|
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
|
|
setScreenDimensions(screen);
|
|
// Re-apply immersive mode on layout changes (Android) - only after opening animation
|
|
if (isOpeningAnimationComplete) {
|
|
enableImmersiveMode();
|
|
}
|
|
});
|
|
const initializePlayer = async () => {
|
|
StatusBar.setHidden(true, 'none');
|
|
// Enable immersive mode after opening animation to prevent glitches
|
|
if (isOpeningAnimationComplete) {
|
|
enableImmersiveMode();
|
|
}
|
|
startOpeningAnimation();
|
|
|
|
// Initialize current volume and brightness levels
|
|
// Volume starts at 100 (full volume) for KSPlayer
|
|
setVolume(100);
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Initial volume: 100 (KSPlayer native)`);
|
|
}
|
|
|
|
try {
|
|
const currentBrightness = await Brightness.getBrightnessAsync();
|
|
setBrightness(currentBrightness);
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Initial brightness: ${currentBrightness}`);
|
|
}
|
|
} catch (error) {
|
|
logger.warn('[VideoPlayer] Error getting initial brightness:', error);
|
|
// Fallback to 1.0 if brightness API fails
|
|
setBrightness(1.0);
|
|
}
|
|
};
|
|
initializePlayer();
|
|
return () => {
|
|
subscription?.remove();
|
|
disableImmersiveMode();
|
|
};
|
|
}, [isOpeningAnimationComplete]);
|
|
|
|
// Re-apply immersive mode when screen gains focus (Android)
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (isOpeningAnimationComplete) {
|
|
enableImmersiveMode();
|
|
}
|
|
return () => {};
|
|
}, [isOpeningAnimationComplete])
|
|
);
|
|
|
|
// Re-apply immersive mode when app returns to foreground (Android)
|
|
useEffect(() => {
|
|
const onAppStateChange = (state: string) => {
|
|
if (state === 'active' && isOpeningAnimationComplete) {
|
|
enableImmersiveMode();
|
|
}
|
|
};
|
|
const sub = AppState.addEventListener('change', onAppStateChange);
|
|
return () => {
|
|
sub.remove();
|
|
};
|
|
}, [isOpeningAnimationComplete]);
|
|
|
|
const startOpeningAnimation = () => {
|
|
// Logo entrance animation - optimized for faster appearance
|
|
Animated.parallel([
|
|
Animated.timing(logoOpacityAnim, {
|
|
toValue: 1,
|
|
duration: 300, // Reduced from 600ms to 300ms
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.spring(logoScaleAnim, {
|
|
toValue: 1,
|
|
tension: 80, // Increased tension for faster spring
|
|
friction: 8,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
|
|
// Continuous pulse animation for the logo
|
|
const createPulseAnimation = () => {
|
|
return Animated.sequence([
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1.05,
|
|
duration: 800, // Reduced from 1000ms to 800ms
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1,
|
|
duration: 800, // Reduced from 1000ms to 800ms
|
|
useNativeDriver: true,
|
|
}),
|
|
]);
|
|
};
|
|
|
|
const loopPulse = () => {
|
|
createPulseAnimation().start(() => {
|
|
if (!isOpeningAnimationComplete) {
|
|
loopPulse();
|
|
}
|
|
});
|
|
};
|
|
|
|
// Start pulsing immediately without delay
|
|
// Removed the 800ms delay
|
|
loopPulse();
|
|
};
|
|
|
|
const completeOpeningAnimation = () => {
|
|
Animated.parallel([
|
|
Animated.timing(openingFadeAnim, {
|
|
toValue: 1,
|
|
duration: 300, // Reduced from 600ms to 300ms
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(openingScaleAnim, {
|
|
toValue: 1,
|
|
duration: 350, // Reduced from 700ms to 350ms
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(backgroundFadeAnim, {
|
|
toValue: 0,
|
|
duration: 400, // Reduced from 800ms to 400ms
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start(() => {
|
|
setIsOpeningAnimationComplete(true);
|
|
// Delay hiding the overlay to allow background fade animation to complete
|
|
setTimeout(() => {
|
|
setShouldHideOpeningOverlay(true);
|
|
}, 450); // Slightly longer than the background fade duration
|
|
// Enable immersive mode and lock orientation now that animation is complete
|
|
enableImmersiveMode();
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const loadWatchProgress = async () => {
|
|
if (id && type) {
|
|
try {
|
|
if (__DEV__) {
|
|
logger.log(`[VideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
|
|
}
|
|
const savedProgress = await storageService.getWatchProgress(id, type, episodeId);
|
|
if (__DEV__) {
|
|
logger.log(`[VideoPlayer] Saved progress:`, savedProgress);
|
|
}
|
|
|
|
if (savedProgress) {
|
|
const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100;
|
|
if (__DEV__) logger.log(`[VideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`);
|
|
|
|
if (progressPercent < 85) {
|
|
setResumePosition(savedProgress.currentTime);
|
|
setSavedDuration(savedProgress.duration);
|
|
if (__DEV__) logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`);
|
|
if (appSettings.alwaysResume) {
|
|
// Only prepare auto-resume state and seek when AlwaysResume is enabled
|
|
setInitialPosition(savedProgress.currentTime);
|
|
initialSeekTargetRef.current = savedProgress.currentTime;
|
|
if (__DEV__) logger.log(`[VideoPlayer] AlwaysResume enabled. Auto-seeking to ${savedProgress.currentTime}`);
|
|
// Seek immediately after load
|
|
seekToTime(savedProgress.currentTime);
|
|
} else {
|
|
// Do not set initialPosition; start from beginning with no auto-seek
|
|
setShowResumeOverlay(true);
|
|
if (__DEV__) logger.log(`[VideoPlayer] AlwaysResume disabled. Not auto-seeking; overlay shown (if enabled)`);
|
|
}
|
|
} else {
|
|
if (__DEV__) logger.log(`[VideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`);
|
|
}
|
|
} else {
|
|
logger.log(`[VideoPlayer] No saved progress found`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error loading watch progress:', error);
|
|
}
|
|
} else {
|
|
if (__DEV__) logger.log(`[VideoPlayer] Missing id or type: id=${id}, type=${type}`);
|
|
}
|
|
};
|
|
loadWatchProgress();
|
|
}, [id, type, episodeId, appSettings.alwaysResume]);
|
|
|
|
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);
|
|
|
|
// Sync to Trakt if authenticated
|
|
await traktAutosync.handleProgressUpdate(currentTime, duration);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error saving watch progress:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (id && type && !paused && duration > 0) {
|
|
if (progressSaveInterval) {
|
|
clearInterval(progressSaveInterval);
|
|
}
|
|
|
|
const syncInterval = 20000; // 20s to further reduce CPU load
|
|
|
|
const interval = setInterval(() => {
|
|
saveWatchProgress();
|
|
}, syncInterval);
|
|
|
|
setProgressSaveInterval(interval);
|
|
return () => {
|
|
clearInterval(interval);
|
|
setProgressSaveInterval(null);
|
|
};
|
|
}
|
|
}, [id, type, paused, duration]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (id && type && duration > 0) {
|
|
saveWatchProgress();
|
|
// Final Trakt sync on component unmount
|
|
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
|
|
}
|
|
};
|
|
}, [id, type, currentTime, duration]);
|
|
|
|
const onPlaying = () => {
|
|
if (isMounted.current && !isSeeking.current) {
|
|
setPaused(false);
|
|
|
|
// Note: handlePlaybackStart is already called in onLoad
|
|
// We don't need to call it again here to avoid duplicate calls
|
|
}
|
|
};
|
|
|
|
const onPaused = () => {
|
|
if (isMounted.current) {
|
|
setPaused(true);
|
|
|
|
// IMMEDIATE: Send immediate pause update to Trakt when user pauses
|
|
if (duration > 0) {
|
|
traktAutosync.handleProgressUpdate(currentTime, duration, true); // force=true triggers immediate sync
|
|
}
|
|
}
|
|
};
|
|
|
|
const seekToTime = (rawSeconds: number) => {
|
|
// For KSPlayer, we need to wait for the player to be ready
|
|
if (!ksPlayerRef.current || isSeeking.current) {
|
|
if (DEBUG_MODE) {
|
|
logger.error(`[VideoPlayer] Seek failed: ksPlayerRef=${!!ksPlayerRef.current}, seeking=${isSeeking.current}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Clamp to just before the end to avoid triggering onEnd when duration is known.
|
|
const timeInSeconds = duration > 0
|
|
? Math.max(0, Math.min(rawSeconds, duration - END_EPSILON))
|
|
: Math.max(0, rawSeconds);
|
|
|
|
if (DEBUG_MODE) {
|
|
if (__DEV__) logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`);
|
|
}
|
|
|
|
isSeeking.current = true;
|
|
|
|
// KSPlayer uses direct time seeking
|
|
ksPlayerRef.current.seek(timeInSeconds);
|
|
|
|
setTimeout(() => {
|
|
if (isMounted.current) {
|
|
isSeeking.current = false;
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] KSPlayer seek completed to ${timeInSeconds.toFixed(2)}s`);
|
|
}
|
|
}
|
|
}, 500);
|
|
};
|
|
|
|
// Slider callback functions for React Native Community Slider
|
|
const handleSliderValueChange = (value: number) => {
|
|
if (isDragging && duration > 0) {
|
|
const seekTime = Math.min(value, duration - END_EPSILON);
|
|
setCurrentTime(seekTime);
|
|
pendingSeekValue.current = seekTime;
|
|
}
|
|
};
|
|
|
|
const handleSlidingStart = () => {
|
|
setIsDragging(true);
|
|
// Remember if we were playing before the user started dragging
|
|
wasPlayingBeforeDragRef.current = !paused;
|
|
// Keep controls visible while dragging and cancel any hide timeout
|
|
if (!showControls) setShowControls(true);
|
|
if (controlsTimeout.current) {
|
|
clearTimeout(controlsTimeout.current);
|
|
controlsTimeout.current = null;
|
|
}
|
|
};
|
|
|
|
const handleSlidingComplete = (value: number) => {
|
|
setIsDragging(false);
|
|
if (duration > 0) {
|
|
const seekTime = Math.min(value, duration - END_EPSILON);
|
|
seekToTime(seekTime);
|
|
// If the video was playing before the drag, ensure we remain in playing state after the seek
|
|
if (wasPlayingBeforeDragRef.current) {
|
|
setTimeout(() => {
|
|
if (isMounted.current) {
|
|
setPaused(false);
|
|
}
|
|
}, 350);
|
|
}
|
|
pendingSeekValue.current = null;
|
|
}
|
|
// Restart auto-hide timer after interaction finishes
|
|
if (controlsTimeout.current) {
|
|
clearTimeout(controlsTimeout.current);
|
|
}
|
|
if (!showControls) setShowControls(true);
|
|
controlsTimeout.current = setTimeout(hideControls, 5000);
|
|
};
|
|
|
|
// Ensure auto-hide resumes after drag ends
|
|
useEffect(() => {
|
|
if (!isDragging && showControls) {
|
|
if (controlsTimeout.current) {
|
|
clearTimeout(controlsTimeout.current);
|
|
}
|
|
controlsTimeout.current = setTimeout(hideControls, 5000);
|
|
}
|
|
}, [isDragging, showControls]);
|
|
|
|
// Removed processProgressTouch - no longer needed with React Native Community Slider
|
|
|
|
const handleProgress = (event: any) => {
|
|
if (isDragging || isSeeking.current) return;
|
|
|
|
// KSPlayer returns times in seconds directly
|
|
const currentTimeInSeconds = event.currentTime;
|
|
const durationInSeconds = event.duration;
|
|
|
|
// Update duration if it's available and different
|
|
if (durationInSeconds > 0 && durationInSeconds !== duration) {
|
|
setDuration(durationInSeconds);
|
|
}
|
|
|
|
// Only update if there's a significant change to avoid unnecessary updates
|
|
if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) {
|
|
safeSetState(() => setCurrentTime(currentTimeInSeconds));
|
|
// KSPlayer returns bufferTime in seconds
|
|
const bufferedTime = event.bufferTime || currentTimeInSeconds;
|
|
safeSetState(() => setBuffered(bufferedTime));
|
|
}
|
|
|
|
// Safety: if audio is advancing but onLoad didn't fire, dismiss opening overlay
|
|
if (!isOpeningAnimationComplete) {
|
|
setIsVideoLoaded(true);
|
|
setIsPlayerReady(true);
|
|
completeOpeningAnimation();
|
|
}
|
|
|
|
// If time is advancing right after seek and we previously intended to play,
|
|
// ensure paused state is false to keep UI in sync
|
|
if (wasPlayingBeforeDragRef.current && paused && !isDragging) {
|
|
setPaused(false);
|
|
// Reset the intent once corrected
|
|
wasPlayingBeforeDragRef.current = false;
|
|
}
|
|
|
|
// Periodic check for disabled audio track (every 3 seconds, max 3 attempts)
|
|
const now = Date.now();
|
|
if (now - lastAudioTrackCheck > 3000 && !paused && duration > 0 && audioTrackFallbackAttempts < 3) {
|
|
setLastAudioTrackCheck(now);
|
|
|
|
// Check if audio track is disabled (-1) and we have available tracks
|
|
if (selectedAudioTrack === -1 && ksAudioTracks.length > 1) {
|
|
logger.warn('[VideoPlayer] Detected disabled audio track, attempting fallback');
|
|
|
|
// Find a fallback audio track (prefer stereo/standard formats)
|
|
const fallbackTrack = ksAudioTracks.find((track, index) => {
|
|
const trackName = (track.name || '').toLowerCase();
|
|
const trackLang = (track.language || '').toLowerCase();
|
|
// Prefer stereo, AAC, or standard audio formats, avoid heavy codecs
|
|
return !trackName.includes('truehd') &&
|
|
!trackName.includes('dts') &&
|
|
!trackName.includes('dolby') &&
|
|
!trackName.includes('atmos') &&
|
|
!trackName.includes('7.1') &&
|
|
!trackName.includes('5.1') &&
|
|
index !== selectedAudioTrack; // Don't select the same track
|
|
});
|
|
|
|
if (fallbackTrack) {
|
|
const fallbackIndex = ksAudioTracks.indexOf(fallbackTrack);
|
|
logger.warn(`[VideoPlayer] Switching to fallback audio track: ${fallbackTrack.name || 'Unknown'} (index: ${fallbackIndex})`);
|
|
|
|
// Increment fallback attempts counter
|
|
setAudioTrackFallbackAttempts(prev => prev + 1);
|
|
|
|
// Switch to fallback audio track
|
|
setSelectedAudioTrack(fallbackIndex);
|
|
|
|
// Brief pause to allow track switching
|
|
setPaused(true);
|
|
setTimeout(() => {
|
|
if (isMounted.current) {
|
|
setPaused(false);
|
|
}
|
|
}, 500);
|
|
} else {
|
|
logger.warn('[VideoPlayer] No suitable fallback audio track found');
|
|
// Increment attempts even if no fallback found to prevent infinite checking
|
|
setAudioTrackFallbackAttempts(prev => prev + 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const onLoad = (data: any) => {
|
|
try {
|
|
if (DEBUG_MODE) {
|
|
logger.log('[VideoPlayer] Video loaded:', data);
|
|
}
|
|
// Clear any pending startup silent retry timers and counters on success
|
|
if (startupRetryTimerRef.current) {
|
|
clearTimeout(startupRetryTimerRef.current);
|
|
startupRetryTimerRef.current = null;
|
|
}
|
|
startupRetryCountRef.current = 0;
|
|
if (!isMounted.current) {
|
|
logger.warn('[VideoPlayer] Component unmounted, skipping onLoad');
|
|
return;
|
|
}
|
|
if (!data) {
|
|
logger.error('[VideoPlayer] onLoad called with null/undefined data');
|
|
return;
|
|
}
|
|
// KSPlayer returns duration in seconds directly
|
|
const videoDuration = data.duration;
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Setting duration to: ${videoDuration}`);
|
|
}
|
|
if (videoDuration > 0) {
|
|
setDuration(videoDuration);
|
|
|
|
// Store the actual duration for future reference and update existing progress
|
|
if (id && type) {
|
|
storageService.setContentDuration(id, type, videoDuration, episodeId);
|
|
storageService.updateProgressDuration(id, type, videoDuration, episodeId);
|
|
|
|
// Update the saved duration for resume overlay if it was using an estimate
|
|
if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) {
|
|
setSavedDuration(videoDuration);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set aspect ratio from naturalSize (KSPlayer format)
|
|
if (data.naturalSize && data.naturalSize.width && data.naturalSize.height) {
|
|
setVideoAspectRatio(data.naturalSize.width / data.naturalSize.height);
|
|
} else {
|
|
// Fallback to 16:9 aspect ratio if naturalSize is not available
|
|
setVideoAspectRatio(16 / 9);
|
|
logger.warn('[VideoPlayer] naturalSize not available, using default 16:9 aspect ratio');
|
|
}
|
|
|
|
if (data.audioTracks && data.audioTracks.length > 0) {
|
|
// Enhanced debug logging to see all available fields
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Raw audio tracks data:`, data.audioTracks);
|
|
data.audioTracks.forEach((track: any, idx: number) => {
|
|
logger.log(`[VideoPlayer] Track ${idx} raw data:`, {
|
|
id: track.id,
|
|
name: track.name,
|
|
language: track.language,
|
|
languageCode: track.languageCode,
|
|
isEnabled: track.isEnabled,
|
|
bitRate: track.bitRate,
|
|
bitDepth: track.bitDepth,
|
|
allKeys: Object.keys(track),
|
|
fullTrackObject: track
|
|
});
|
|
});
|
|
}
|
|
|
|
const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => {
|
|
const trackIndex = track.id !== undefined ? track.id : index;
|
|
|
|
// Build comprehensive track name from available fields
|
|
let trackName = '';
|
|
const parts = [];
|
|
|
|
// Add language if available
|
|
let language = track.language || track.languageCode;
|
|
|
|
if (language && language !== 'Unknown' && language !== 'und' && language !== '') {
|
|
parts.push(language.toUpperCase());
|
|
}
|
|
|
|
// Add bitrate if available
|
|
const bitrate = track.bitRate;
|
|
if (bitrate && bitrate > 0) {
|
|
parts.push(`${Math.round(bitrate / 1000)}kbps`);
|
|
}
|
|
|
|
// Add bit depth if available
|
|
const bitDepth = track.bitDepth;
|
|
if (bitDepth && bitDepth > 0) {
|
|
parts.push(`${bitDepth}bit`);
|
|
}
|
|
|
|
// Add track name if available and not generic
|
|
let title = track.name;
|
|
if (title && !title.match(/^(Audio|Track)\s*\d*$/i) && title !== 'Unknown') {
|
|
// Clean up title by removing language brackets and trailing punctuation
|
|
title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim();
|
|
if (title && title !== 'Unknown') {
|
|
parts.push(title);
|
|
}
|
|
}
|
|
|
|
// Combine parts or fallback to generic name
|
|
if (parts.length > 0) {
|
|
trackName = parts.join(' • ');
|
|
} else {
|
|
// For simple track names like "Track 1", "Audio 1", etc., use them as-is
|
|
const simpleName = track.name;
|
|
if (simpleName && simpleName.match(/^(Track|Audio)\s*\d*$/i)) {
|
|
trackName = simpleName;
|
|
} else {
|
|
trackName = `Audio ${index + 1}`;
|
|
}
|
|
}
|
|
|
|
const trackLanguage = language || 'Unknown';
|
|
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Processed KSPlayer track ${index}:`, {
|
|
id: trackIndex,
|
|
name: trackName,
|
|
language: trackLanguage,
|
|
parts: parts,
|
|
bitRate: bitrate,
|
|
bitDepth: bitDepth
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: trackIndex, // Use the actual track ID from KSPlayer
|
|
name: trackName,
|
|
language: trackLanguage,
|
|
};
|
|
});
|
|
setKsAudioTracks(formattedAudioTracks);
|
|
|
|
// Auto-select English audio track if available, otherwise first track
|
|
if (selectedAudioTrack === null && formattedAudioTracks.length > 0) {
|
|
// Look for English track first
|
|
const englishTrack = formattedAudioTracks.find((track: {id: number, name: string, language?: string}) => {
|
|
const lang = (track.language || '').toLowerCase();
|
|
return lang === 'english' || lang === 'en' || lang === 'eng' ||
|
|
(track.name && track.name.toLowerCase().includes('english'));
|
|
});
|
|
|
|
const selectedTrack = englishTrack || formattedAudioTracks[0];
|
|
setSelectedAudioTrack(selectedTrack.id);
|
|
|
|
if (DEBUG_MODE) {
|
|
if (englishTrack) {
|
|
logger.log(`[VideoPlayer] Auto-selected English audio track: ${selectedTrack.name} (ID: ${selectedTrack.id})`);
|
|
} else {
|
|
logger.log(`[VideoPlayer] No English track found, auto-selected first audio track: ${selectedTrack.name} (ID: ${selectedTrack.id})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Formatted audio tracks:`, formattedAudioTracks);
|
|
}
|
|
}
|
|
if (data.textTracks && data.textTracks.length > 0) {
|
|
// Process KSPlayer text tracks
|
|
const formattedTextTracks = data.textTracks.map((track: any, index: number) => ({
|
|
id: track.id !== undefined ? track.id : index,
|
|
name: track.name || `Subtitle ${index + 1}`,
|
|
language: track.language || track.languageCode || 'Unknown',
|
|
isEnabled: track.isEnabled || false,
|
|
isImageSubtitle: track.isImageSubtitle || false
|
|
}));
|
|
|
|
setKsTextTracks(formattedTextTracks);
|
|
|
|
// Auto-select English subtitle track if available
|
|
if (selectedTextTrack === -1 && !useCustomSubtitles && formattedTextTracks.length > 0) {
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Available KSPlayer subtitle tracks:`, formattedTextTracks);
|
|
}
|
|
|
|
// Look for English track first
|
|
const englishTrack = formattedTextTracks.find((track: any) => {
|
|
const lang = (track.language || '').toLowerCase();
|
|
const name = (track.name || '').toLowerCase();
|
|
return lang === 'english' || lang === 'en' || lang === 'eng' ||
|
|
name.includes('english') || name.includes('en');
|
|
});
|
|
|
|
if (englishTrack) {
|
|
setSelectedTextTrack(englishTrack.id);
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Auto-selected English subtitle track: ${englishTrack.name} (ID: ${englishTrack.id})`);
|
|
}
|
|
} else if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] No English subtitle track found, keeping subtitles disabled`);
|
|
}
|
|
}
|
|
}
|
|
|
|
setIsVideoLoaded(true);
|
|
setIsPlayerReady(true);
|
|
|
|
// Reset audio track fallback attempts when new video loads
|
|
setAudioTrackFallbackAttempts(0);
|
|
setLastAudioTrackCheck(0);
|
|
|
|
// Start Trakt watching session when video loads with proper duration
|
|
if (videoDuration > 0) {
|
|
traktAutosync.handlePlaybackStart(currentTime, videoDuration);
|
|
}
|
|
|
|
// Complete opening animation immediately before seeking
|
|
completeOpeningAnimation();
|
|
|
|
if (initialPosition && !isInitialSeekComplete) {
|
|
logger.log(`[VideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`);
|
|
// Reduced timeout from 1000ms to 500ms
|
|
setTimeout(() => {
|
|
if (videoDuration > 0 && isMounted.current) {
|
|
seekToTime(initialPosition);
|
|
setIsInitialSeekComplete(true);
|
|
logger.log(`[VideoPlayer] Initial seek completed to: ${initialPosition}s`);
|
|
} else {
|
|
logger.error(`[VideoPlayer] Initial seek failed: duration=${videoDuration}, mounted=${isMounted.current}`);
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
controlsTimeout.current = setTimeout(hideControls, 5000);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error in onLoad:', error);
|
|
// Set fallback values to prevent crashes
|
|
if (isMounted.current) {
|
|
setVideoAspectRatio(16 / 9);
|
|
setIsVideoLoaded(true);
|
|
setIsPlayerReady(true);
|
|
completeOpeningAnimation();
|
|
}
|
|
}
|
|
};
|
|
|
|
const skip = (seconds: number) => {
|
|
const newTime = Math.max(0, Math.min(currentTime + seconds, duration - END_EPSILON));
|
|
seekToTime(newTime);
|
|
};
|
|
|
|
const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => {
|
|
setAudioTracks(data.audioTracks || []);
|
|
};
|
|
|
|
const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => {
|
|
setTextTracks(e.textTracks || []);
|
|
};
|
|
|
|
const cycleAspectRatio = () => {
|
|
const newZoom = zoomScale === 1.1 ? 1 : 1.1;
|
|
setZoomScale(newZoom);
|
|
setZoomTranslateX(0);
|
|
setZoomTranslateY(0);
|
|
setLastZoomScale(newZoom);
|
|
setLastTranslateX(0);
|
|
setLastTranslateY(0);
|
|
};
|
|
|
|
const enableImmersiveMode = () => {
|
|
StatusBar.setHidden(true, 'none');
|
|
if (Platform.OS === 'android') {
|
|
try {
|
|
RNImmersiveMode.setBarMode('FullSticky');
|
|
RNImmersiveMode.fullLayout(true);
|
|
if (NativeModules.StatusBarManager) {
|
|
NativeModules.StatusBarManager.setHidden(true);
|
|
}
|
|
} catch (error) {
|
|
if (__DEV__) console.log('Immersive mode error:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const disableImmersiveMode = () => {
|
|
StatusBar.setHidden(false);
|
|
if (Platform.OS === 'android') {
|
|
RNImmersiveMode.setBarMode('Normal');
|
|
RNImmersiveMode.fullLayout(false);
|
|
}
|
|
};
|
|
|
|
const handleClose = async () => {
|
|
// Prevent multiple close attempts
|
|
if (isSyncingBeforeClose) {
|
|
logger.log('[VideoPlayer] Close already in progress, ignoring duplicate call');
|
|
return;
|
|
}
|
|
|
|
logger.log('[VideoPlayer] Close button pressed - closing immediately and syncing to Trakt in background');
|
|
setIsSyncingBeforeClose(true);
|
|
|
|
// Make sure we have the most accurate current time
|
|
const actualCurrentTime = currentTime;
|
|
const progressPercent = duration > 0 ? (actualCurrentTime / duration) * 100 : 0;
|
|
|
|
logger.log(`[VideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`);
|
|
|
|
// Cleanup and navigate back immediately without delay
|
|
const cleanup = async () => {
|
|
try {
|
|
// Unlock orientation first
|
|
await ScreenOrientation.unlockAsync();
|
|
logger.log('[VideoPlayer] Orientation unlocked');
|
|
} catch (orientationError) {
|
|
logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError);
|
|
}
|
|
|
|
// On iOS tablets, keep rotation unlocked; on phones, return to portrait
|
|
if (Platform.OS === 'ios') {
|
|
const { width: dw, height: dh } = Dimensions.get('window');
|
|
const isTablet = (Platform as any).isPad === true || Math.min(dw, dh) >= 768;
|
|
setTimeout(() => {
|
|
if (isTablet) {
|
|
ScreenOrientation.unlockAsync().catch(() => {});
|
|
} else {
|
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
// Disable immersive mode
|
|
disableImmersiveMode();
|
|
|
|
// Navigate back to previous screen (StreamsScreen expected to be below Player)
|
|
try {
|
|
if (navigation.canGoBack()) {
|
|
navigation.goBack();
|
|
} else {
|
|
// Fallback: navigate to Streams if stack was not set as expected
|
|
(navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true });
|
|
}
|
|
logger.log('[VideoPlayer] Navigation completed');
|
|
} catch (navError) {
|
|
logger.error('[VideoPlayer] Navigation error:', navError);
|
|
// Last resort: try to navigate to Streams
|
|
(navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true });
|
|
}
|
|
};
|
|
|
|
// Navigate immediately
|
|
cleanup();
|
|
|
|
// Send Trakt sync in background (don't await)
|
|
const backgroundSync = async () => {
|
|
try {
|
|
logger.log('[VideoPlayer] Starting background Trakt sync');
|
|
// IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time
|
|
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
|
|
|
|
// IMMEDIATE: Use user_close reason to trigger immediate scrobble stop
|
|
await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'user_close');
|
|
|
|
logger.log('[VideoPlayer] Background Trakt sync completed successfully');
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error in background Trakt sync:', error);
|
|
}
|
|
};
|
|
|
|
// Start background sync without blocking UI
|
|
backgroundSync();
|
|
};
|
|
|
|
const handleResume = async () => {
|
|
if (resumePosition) {
|
|
seekToTime(resumePosition);
|
|
}
|
|
setShowResumeOverlay(false);
|
|
};
|
|
|
|
const handleStartFromBeginning = async () => {
|
|
seekToTime(0);
|
|
setShowResumeOverlay(false);
|
|
};
|
|
|
|
const toggleControls = () => {
|
|
if (controlsTimeout.current) {
|
|
clearTimeout(controlsTimeout.current);
|
|
controlsTimeout.current = null;
|
|
}
|
|
|
|
setShowControls(prevShowControls => {
|
|
const newShowControls = !prevShowControls;
|
|
Animated.timing(fadeAnim, {
|
|
toValue: newShowControls ? 1 : 0,
|
|
duration: 300,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
if (newShowControls) {
|
|
controlsTimeout.current = setTimeout(hideControls, 5000);
|
|
}
|
|
// Reinforce immersive mode after any UI toggle (Android)
|
|
enableImmersiveMode();
|
|
return newShowControls;
|
|
});
|
|
};
|
|
|
|
const handleError = (error: any) => {
|
|
try {
|
|
logger.error('[VideoPlayer] Playback Error:', error);
|
|
|
|
// Detect KSPlayer startup timeout and silently retry without UI
|
|
const errText = typeof error === 'string'
|
|
? error
|
|
: (error?.message || error?.error?.message || error?.title || '');
|
|
const isStartupTimeout = /timeout/i.test(errText) && /stream.*ready/i.test(errText);
|
|
if (isStartupTimeout && !isVideoLoaded) {
|
|
// Suppress any error modal and retry silently
|
|
if (errorTimeoutRef.current) {
|
|
clearTimeout(errorTimeoutRef.current);
|
|
errorTimeoutRef.current = null;
|
|
}
|
|
setShowErrorModal(false);
|
|
|
|
const attempt = startupRetryCountRef.current;
|
|
if (attempt < MAX_STARTUP_RETRIES) {
|
|
const backoffMs = [4000, 8000, 12000][attempt] ?? 8000;
|
|
startupRetryCountRef.current = attempt + 1;
|
|
logger.warn(`[VideoPlayer] Startup timeout; retrying (${attempt + 1}/${MAX_STARTUP_RETRIES}) in ${backoffMs}ms`);
|
|
|
|
if (startupRetryTimerRef.current) {
|
|
clearTimeout(startupRetryTimerRef.current);
|
|
}
|
|
startupRetryTimerRef.current = setTimeout(() => {
|
|
if (!ksPlayerRef.current) return;
|
|
try {
|
|
// Reload the same source silently using native bridge
|
|
ksPlayerRef.current.setSource({
|
|
uri: currentStreamUrl,
|
|
headers: headers && Object.keys(headers).length > 0 ? headers : undefined
|
|
});
|
|
// Ensure playback resumes if not paused
|
|
ksPlayerRef.current.setPaused(paused);
|
|
logger.log('[VideoPlayer] Retried source load via KSPlayer.setSource');
|
|
} catch (e) {
|
|
logger.error('[VideoPlayer] Error during silent retry setSource:', e);
|
|
}
|
|
}, backoffMs);
|
|
return; // Exit handler; do not show UI
|
|
}
|
|
logger.error('[VideoPlayer] Max startup retries reached; proceeding to normal error handling');
|
|
}
|
|
|
|
// Check for audio codec errors (TrueHD, DTS, Dolby, etc.)
|
|
const isAudioCodecError =
|
|
(error?.message && /(trhd|truehd|true\s?hd|dts|dolby|atmos|e-ac3|ac3)/i.test(error.message)) ||
|
|
(error?.error?.message && /(trhd|truehd|true\s?hd|dts|dolby|atmos|e-ac3|ac3)/i.test(error.error.message)) ||
|
|
(error?.title && /codec not supported/i.test(error.title));
|
|
|
|
// Handle audio codec errors with automatic fallback
|
|
if (isAudioCodecError && ksAudioTracks.length > 1) {
|
|
logger.warn('[VideoPlayer] Audio codec error detected, attempting audio track fallback');
|
|
|
|
// Find a fallback audio track (prefer stereo/standard formats)
|
|
const fallbackTrack = ksAudioTracks.find((track, index) => {
|
|
const trackName = (track.name || '').toLowerCase();
|
|
const trackLang = (track.language || '').toLowerCase();
|
|
// Prefer stereo, AAC, or standard audio formats, avoid heavy codecs
|
|
return !trackName.includes('truehd') &&
|
|
!trackName.includes('dts') &&
|
|
!trackName.includes('dolby') &&
|
|
!trackName.includes('atmos') &&
|
|
!trackName.includes('7.1') &&
|
|
!trackName.includes('5.1') &&
|
|
index !== selectedAudioTrack; // Don't select the same track
|
|
});
|
|
|
|
if (fallbackTrack) {
|
|
const fallbackIndex = ksAudioTracks.indexOf(fallbackTrack);
|
|
logger.warn(`[VideoPlayer] Switching to fallback audio track: ${fallbackTrack.name || 'Unknown'} (index: ${fallbackIndex})`);
|
|
|
|
// Clear any existing error state
|
|
if (errorTimeoutRef.current) {
|
|
clearTimeout(errorTimeoutRef.current);
|
|
errorTimeoutRef.current = null;
|
|
}
|
|
setShowErrorModal(false);
|
|
|
|
// Switch to fallback audio track
|
|
setSelectedAudioTrack(fallbackIndex);
|
|
|
|
// Brief pause to allow track switching
|
|
setPaused(true);
|
|
setTimeout(() => {
|
|
if (isMounted.current) {
|
|
setPaused(false);
|
|
}
|
|
}, 500);
|
|
|
|
return; // Don't show error UI, attempt recovery
|
|
}
|
|
}
|
|
|
|
// Format error details for user display
|
|
let errorMessage = 'An unknown error occurred';
|
|
if (error) {
|
|
if (isAudioCodecError) {
|
|
errorMessage = 'Audio codec compatibility issue detected. The video contains unsupported audio codec (TrueHD/DTS/Dolby). Please try selecting a different audio track or use an alternative video source.';
|
|
} else if (typeof error === 'string') {
|
|
errorMessage = error;
|
|
} else if (error.message) {
|
|
errorMessage = error.message;
|
|
} else if (error.error && error.error.message) {
|
|
errorMessage = error.error.message;
|
|
} else if (error.code) {
|
|
errorMessage = `Error Code: ${error.code}`;
|
|
} else {
|
|
errorMessage = JSON.stringify(error, null, 2);
|
|
}
|
|
}
|
|
|
|
setErrorDetails(errorMessage);
|
|
setShowErrorModal(true);
|
|
|
|
// Clear any existing timeout
|
|
if (errorTimeoutRef.current) {
|
|
clearTimeout(errorTimeoutRef.current);
|
|
}
|
|
|
|
// Auto-exit after 5 seconds if user doesn't dismiss
|
|
errorTimeoutRef.current = setTimeout(() => {
|
|
handleErrorExit();
|
|
}, 5000);
|
|
} catch (handlerError) {
|
|
// Fallback error handling to prevent crashes during error processing
|
|
logger.error('[VideoPlayer] Error in error handler:', handlerError);
|
|
if (isMounted.current) {
|
|
// Minimal safe error handling
|
|
setErrorDetails('A critical error occurred');
|
|
setShowErrorModal(true);
|
|
// Force exit after 3 seconds if error handler itself fails
|
|
setTimeout(() => {
|
|
if (isMounted.current) {
|
|
handleClose();
|
|
}
|
|
}, 3000);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleErrorExit = () => {
|
|
if (errorTimeoutRef.current) {
|
|
clearTimeout(errorTimeoutRef.current);
|
|
errorTimeoutRef.current = null;
|
|
}
|
|
setShowErrorModal(false);
|
|
handleClose();
|
|
};
|
|
|
|
const onBuffering = (event: any) => {
|
|
setIsBuffering(event.isBuffering);
|
|
};
|
|
|
|
const onEnd = async () => {
|
|
// Make sure we report 100% progress to Trakt
|
|
const finalTime = duration;
|
|
setCurrentTime(finalTime);
|
|
|
|
try {
|
|
// REGULAR: Use regular sync for natural video end (not immediate since it's not user-triggered)
|
|
logger.log('[VideoPlayer] Video ended naturally, sending final progress update with 100%');
|
|
await traktAutosync.handleProgressUpdate(finalTime, duration, false); // force=false for regular sync
|
|
|
|
// REGULAR: Use 'ended' reason for natural video end (uses regular queued method)
|
|
logger.log('[VideoPlayer] Sending final stop call after natural end');
|
|
await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended');
|
|
|
|
logger.log('[VideoPlayer] Completed video end sync to Trakt');
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error syncing to Trakt on video end:', error);
|
|
}
|
|
};
|
|
|
|
const selectAudioTrack = (trackId: number) => {
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Selecting audio track: ${trackId}`);
|
|
logger.log(`[VideoPlayer] Available tracks:`, ksAudioTracks);
|
|
}
|
|
|
|
// Validate that the track exists
|
|
const trackExists = ksAudioTracks.some(track => track.id === trackId);
|
|
if (!trackExists) {
|
|
logger.error(`[VideoPlayer] Audio track ${trackId} not found in available tracks`);
|
|
return;
|
|
}
|
|
|
|
// Get the selected track info for logging
|
|
const selectedTrack = ksAudioTracks.find(track => track.id === trackId);
|
|
if (selectedTrack && DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Switching to track: ${selectedTrack.name} (${selectedTrack.language})`);
|
|
|
|
// Check if this is a multi-channel track that might need downmixing
|
|
const trackName = selectedTrack.name.toLowerCase();
|
|
const isMultiChannel = trackName.includes('5.1') || trackName.includes('7.1') ||
|
|
trackName.includes('truehd') || trackName.includes('dts') ||
|
|
trackName.includes('dolby') || trackName.includes('atmos');
|
|
|
|
if (isMultiChannel) {
|
|
logger.log(`[VideoPlayer] Multi-channel audio track detected: ${selectedTrack.name}`);
|
|
logger.log(`[VideoPlayer] KSPlayer will apply downmixing to ensure dialogue is audible`);
|
|
}
|
|
}
|
|
|
|
// If changing tracks, briefly pause to allow smooth transition
|
|
const wasPlaying = !paused;
|
|
if (wasPlaying) {
|
|
setPaused(true);
|
|
}
|
|
|
|
// Set the new audio track
|
|
setSelectedAudioTrack(trackId);
|
|
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Audio track changed to: ${trackId}`);
|
|
}
|
|
|
|
// Resume playback after a brief delay if it was playing
|
|
if (wasPlaying) {
|
|
setTimeout(() => {
|
|
if (isMounted.current) {
|
|
setPaused(false);
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Resumed playback after audio track change`);
|
|
}
|
|
}
|
|
}, 300);
|
|
}
|
|
};
|
|
|
|
const selectTextTrack = (trackId: number) => {
|
|
if (trackId === -999) {
|
|
setUseCustomSubtitles(true);
|
|
setSelectedTextTrack(-1);
|
|
} else {
|
|
setUseCustomSubtitles(false);
|
|
setSelectedTextTrack(trackId);
|
|
}
|
|
};
|
|
|
|
const disableCustomSubtitles = () => {
|
|
setUseCustomSubtitles(false);
|
|
setCustomSubtitles([]);
|
|
// Reset to first available built-in track or disable all tracks
|
|
setSelectedTextTrack(ksTextTracks.length > 0 ? 0 : -1);
|
|
};
|
|
|
|
// Ensure native KSPlayer text tracks are disabled when using custom (addon) subtitles
|
|
// and re-applied when switching back to built-in tracks. This prevents double-rendering.
|
|
useEffect(() => {
|
|
try {
|
|
if (useCustomSubtitles) {
|
|
// -1 disables native subtitle rendering in KSPlayer
|
|
setSelectedTextTrack(-1);
|
|
} else if (typeof selectedTextTrack === 'number' && selectedTextTrack >= 0) {
|
|
// KSPlayer picks it up via prop
|
|
}
|
|
} catch (e) {
|
|
// no-op: defensive guard in case ref methods are unavailable momentarily
|
|
}
|
|
}, [useCustomSubtitles, selectedTextTrack]);
|
|
|
|
const loadSubtitleSize = async () => {
|
|
try {
|
|
// Prefer scoped subtitle settings
|
|
const saved = await storageService.getSubtitleSettings();
|
|
if (saved && typeof saved.subtitleSize === 'number') {
|
|
setSubtitleSize(saved.subtitleSize);
|
|
return;
|
|
}
|
|
// One-time migrate legacy key if present
|
|
const legacy = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY);
|
|
if (legacy) {
|
|
const migrated = parseInt(legacy, 10);
|
|
if (!Number.isNaN(migrated) && migrated > 0) {
|
|
setSubtitleSize(migrated);
|
|
try {
|
|
const merged = { ...(saved || {}), subtitleSize: migrated };
|
|
await storageService.saveSubtitleSettings(merged);
|
|
} catch {}
|
|
}
|
|
try { await AsyncStorage.removeItem(SUBTITLE_SIZE_KEY); } catch {}
|
|
return;
|
|
}
|
|
// If no saved settings, use default
|
|
setSubtitleSize(DEFAULT_SUBTITLE_SIZE);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error loading subtitle size:', error);
|
|
// Fallback to default on error
|
|
setSubtitleSize(DEFAULT_SUBTITLE_SIZE);
|
|
}
|
|
};
|
|
|
|
const saveSubtitleSize = async (size: number) => {
|
|
try {
|
|
setSubtitleSize(size);
|
|
// Persist via scoped subtitle settings so it survives restarts and account switches
|
|
const saved = await storageService.getSubtitleSettings();
|
|
const next = { ...(saved || {}), subtitleSize: size };
|
|
await storageService.saveSubtitleSettings(next);
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error saving subtitle size:', error);
|
|
}
|
|
};
|
|
|
|
const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => {
|
|
const targetImdbId = imdbIdParam || imdbId;
|
|
if (!targetImdbId) {
|
|
logger.error('[VideoPlayer] No IMDb ID available for subtitle search');
|
|
return;
|
|
}
|
|
setIsLoadingSubtitleList(true);
|
|
try {
|
|
// Fetch from all installed subtitle-capable addons via Stremio
|
|
const stremioType = type === 'series' ? 'series' : 'movie';
|
|
const stremioVideoId = stremioType === 'series' && season && episode
|
|
? `series:${targetImdbId}:${season}:${episode}`
|
|
: undefined;
|
|
const stremioResults = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId);
|
|
const stremioSubs: WyzieSubtitle[] = (stremioResults || []).map(sub => ({
|
|
id: sub.id || `${sub.lang}-${sub.url}`,
|
|
url: sub.url,
|
|
flagUrl: '',
|
|
format: 'srt',
|
|
encoding: 'utf-8',
|
|
media: sub.addonName || sub.addon || '',
|
|
display: sub.lang || 'Unknown',
|
|
language: (sub.lang || '').toLowerCase(),
|
|
isHearingImpaired: false,
|
|
source: sub.addonName || sub.addon || 'Addon',
|
|
}));
|
|
// Sort with English languages first, then alphabetical over full list
|
|
const isEnglish = (s: WyzieSubtitle) => {
|
|
const lang = (s.language || '').toLowerCase();
|
|
const disp = (s.display || '').toLowerCase();
|
|
return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english');
|
|
};
|
|
stremioSubs.sort((a, b) => {
|
|
const aIsEn = isEnglish(a);
|
|
const bIsEn = isEnglish(b);
|
|
if (aIsEn && !bIsEn) return -1;
|
|
if (!aIsEn && bIsEn) return 1;
|
|
return (a.display || '').localeCompare(b.display || '');
|
|
});
|
|
setAvailableSubtitles(stremioSubs);
|
|
if (autoSelectEnglish) {
|
|
const englishSubtitle = stremioSubs.find(sub =>
|
|
sub.language.toLowerCase() === 'eng' ||
|
|
sub.language.toLowerCase() === 'en' ||
|
|
sub.display.toLowerCase().includes('english')
|
|
);
|
|
if (englishSubtitle) {
|
|
loadWyzieSubtitle(englishSubtitle);
|
|
return;
|
|
}
|
|
}
|
|
if (!autoSelectEnglish) {
|
|
// If no English found and not auto-selecting, still open the modal
|
|
setShowSubtitleLanguageModal(true);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error fetching subtitles from OpenSubtitles addon:', error);
|
|
} finally {
|
|
setIsLoadingSubtitleList(false);
|
|
}
|
|
};
|
|
|
|
const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => {
|
|
logger.log(`[VideoPlayer] Subtitle click received: id=${subtitle.id}, lang=${subtitle.language}, url=${subtitle.url}`);
|
|
setShowSubtitleLanguageModal(false);
|
|
setIsLoadingSubtitles(true);
|
|
try {
|
|
logger.log('[VideoPlayer] Fetching subtitle SRT start');
|
|
let srtContent = '';
|
|
try {
|
|
const axiosResp = await axios.get(subtitle.url, {
|
|
timeout: 10000,
|
|
headers: {
|
|
'Accept': 'text/plain, */*',
|
|
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Nuvio/1.0'
|
|
},
|
|
responseType: 'text',
|
|
transitional: { clarifyTimeoutError: true }
|
|
});
|
|
srtContent = typeof axiosResp.data === 'string' ? axiosResp.data : String(axiosResp.data || '');
|
|
} catch (axiosErr: any) {
|
|
logger.warn('[VideoPlayer] Axios subtitle fetch failed, falling back to fetch()', {
|
|
message: axiosErr?.message,
|
|
code: axiosErr?.code
|
|
});
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
try {
|
|
const resp = await fetch(subtitle.url, { signal: controller.signal });
|
|
srtContent = await resp.text();
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
logger.log(`[VideoPlayer] Fetching subtitle SRT done, size=${srtContent.length}`);
|
|
const parsedCues = parseSRT(srtContent);
|
|
logger.log(`[VideoPlayer] Parsed cues count=${parsedCues.length}`);
|
|
|
|
// For KSPlayer on iOS: stop spinner early, then clear-apply and micro-seek nudge
|
|
setIsLoadingSubtitles(false);
|
|
logger.log('[VideoPlayer] isLoadingSubtitles -> false (early)');
|
|
|
|
// Clear existing state
|
|
setUseCustomSubtitles(false);
|
|
logger.log('[VideoPlayer] useCustomSubtitles -> false');
|
|
setCustomSubtitles([]);
|
|
logger.log('[VideoPlayer] customSubtitles -> []');
|
|
setSelectedTextTrack(-1);
|
|
logger.log('[VideoPlayer] selectedTextTrack -> -1');
|
|
|
|
// Apply immediately
|
|
setCustomSubtitles(parsedCues);
|
|
logger.log('[VideoPlayer] customSubtitles <- parsedCues');
|
|
setUseCustomSubtitles(true);
|
|
logger.log('[VideoPlayer] useCustomSubtitles -> true');
|
|
setSelectedTextTrack(-1);
|
|
logger.log('[VideoPlayer] selectedTextTrack -> -1 (disable native while using custom)');
|
|
|
|
// Immediately set current subtitle text
|
|
try {
|
|
const adjustedTime = currentTime + (subtitleOffsetSec || 0);
|
|
const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
|
|
const textNow = cueNow ? cueNow.text : '';
|
|
setCurrentSubtitle(textNow);
|
|
logger.log('[VideoPlayer] currentSubtitle set immediately after apply');
|
|
} catch (e) {
|
|
logger.error('[VideoPlayer] Error setting immediate subtitle', e);
|
|
}
|
|
|
|
// Removed micro-seek nudge
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error);
|
|
setIsLoadingSubtitles(false);
|
|
}
|
|
};
|
|
|
|
const togglePlayback = () => {
|
|
setPaused(!paused);
|
|
};
|
|
|
|
// Handle next episode button press
|
|
const handlePlayNextEpisode = useCallback(async () => {
|
|
if (!nextEpisode || !id || isLoadingNextEpisode) return;
|
|
|
|
setIsLoadingNextEpisode(true);
|
|
|
|
try {
|
|
logger.log('[VideoPlayer] Loading next episode:', nextEpisode);
|
|
|
|
// Create episode ID for next episode using stremioId if available, otherwise construct it
|
|
const nextEpisodeId = nextEpisode.stremioId || `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`;
|
|
|
|
logger.log('[VideoPlayer] Fetching streams for next episode:', nextEpisodeId);
|
|
|
|
// Import stremio service
|
|
const stremioService = require('../../services/stremioService').default;
|
|
|
|
let bestStream: any = null;
|
|
let streamFound = false;
|
|
let completedProviders = 0;
|
|
const expectedProviders = new Set<string>();
|
|
|
|
// Get installed addons to know how many providers to expect
|
|
const installedAddons = stremioService.getInstalledAddons();
|
|
const streamAddons = installedAddons.filter((addon: any) =>
|
|
addon.resources && addon.resources.includes('stream')
|
|
);
|
|
|
|
streamAddons.forEach((addon: any) => expectedProviders.add(addon.id));
|
|
|
|
// Collect all streams from all providers for the sources modal
|
|
const allStreams: { [providerId: string]: { streams: any[]; addonName: string } } = {};
|
|
let hasNavigated = false;
|
|
|
|
// Fetch streams for next episode
|
|
await stremioService.getStreams('series', nextEpisodeId, (streams: any, addonId: any, addonName: any, error: any) => {
|
|
completedProviders++;
|
|
|
|
// Always collect streams from this provider for sources modal (even after navigation)
|
|
if (streams && streams.length > 0) {
|
|
allStreams[addonId] = {
|
|
streams: streams,
|
|
addonName: addonName || addonId
|
|
};
|
|
}
|
|
|
|
// Navigate with first good stream found, but continue collecting streams in background
|
|
if (!hasNavigated && !streamFound && streams && streams.length > 0) {
|
|
// Sort streams by quality and cache status (prefer cached/debrid streams)
|
|
const sortedStreams = streams.sort((a: any, b: any) => {
|
|
const aQuality = parseInt(a.title?.match(/(\d+)p/)?.[1] || '0', 10);
|
|
const bQuality = parseInt(b.title?.match(/(\d+)p/)?.[1] || '0', 10);
|
|
const aCached = a.behaviorHints?.cached || false;
|
|
const bCached = b.behaviorHints?.cached || false;
|
|
|
|
// Prioritize cached streams first
|
|
if (aCached !== bCached) {
|
|
return aCached ? -1 : 1;
|
|
}
|
|
// Then sort by quality (higher quality first)
|
|
return bQuality - aQuality;
|
|
});
|
|
|
|
bestStream = sortedStreams[0];
|
|
streamFound = true;
|
|
hasNavigated = true;
|
|
|
|
// Update loading details for the chip
|
|
const qualityText = (bestStream.title?.match(/(\d+)p/) || [])[1] || null;
|
|
setNextLoadingProvider(addonName || addonId || null);
|
|
setNextLoadingQuality(qualityText);
|
|
setNextLoadingTitle(bestStream.name || bestStream.title || null);
|
|
|
|
logger.log('[VideoPlayer] Found stream for next episode:', bestStream);
|
|
|
|
// Pause current playback to ensure no background player remains active
|
|
setPaused(true);
|
|
|
|
// Start navigation immediately but let stream fetching continue in background
|
|
setTimeout(() => {
|
|
navigation.replace('PlayerIOS', {
|
|
uri: bestStream.url,
|
|
title: metadata?.name || '',
|
|
episodeTitle: nextEpisode.name,
|
|
season: nextEpisode.season_number,
|
|
episode: nextEpisode.episode_number,
|
|
quality: (bestStream.title?.match(/(\d+)p/) || [])[1] || undefined,
|
|
year: metadata?.year,
|
|
streamProvider: addonName,
|
|
streamName: bestStream.name || bestStream.title,
|
|
headers: bestStream.headers || undefined,
|
|
id,
|
|
type: 'series',
|
|
episodeId: nextEpisodeId,
|
|
imdbId: imdbId ?? undefined,
|
|
backdrop: backdrop || undefined,
|
|
availableStreams: allStreams, // Pass current available streams (more will be added)
|
|
});
|
|
setIsLoadingNextEpisode(false);
|
|
}, 100); // Small delay to ensure smooth transition
|
|
}
|
|
|
|
// If we've checked all providers and no stream found
|
|
if (completedProviders >= expectedProviders.size && !streamFound) {
|
|
logger.warn('[VideoPlayer] No streams found for next episode after checking all providers');
|
|
setIsLoadingNextEpisode(false);
|
|
}
|
|
});
|
|
|
|
// Fallback timeout in case providers don't respond
|
|
setTimeout(() => {
|
|
if (!streamFound) {
|
|
logger.warn('[VideoPlayer] Timeout: No streams found for next episode');
|
|
setIsLoadingNextEpisode(false);
|
|
}
|
|
}, 8000);
|
|
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error loading next episode:', error);
|
|
setIsLoadingNextEpisode(false);
|
|
}
|
|
}, [nextEpisode, id, isLoadingNextEpisode, navigation, metadata, imdbId, backdrop]);
|
|
|
|
// Function to hide pause overlay and show controls
|
|
const hidePauseOverlay = useCallback(() => {
|
|
if (showPauseOverlay) {
|
|
// Reset cast details state when hiding overlay
|
|
if (showCastDetails) {
|
|
Animated.parallel([
|
|
Animated.timing(castDetailsOpacity, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(castDetailsScale, {
|
|
toValue: 0.95,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
})
|
|
]).start(() => {
|
|
setShowCastDetails(false);
|
|
setSelectedCastMember(null);
|
|
// Reset metadata animations
|
|
metadataOpacity.setValue(1);
|
|
metadataScale.setValue(1);
|
|
});
|
|
} else {
|
|
setShowCastDetails(false);
|
|
setSelectedCastMember(null);
|
|
// Reset metadata animations
|
|
metadataOpacity.setValue(1);
|
|
metadataScale.setValue(1);
|
|
}
|
|
|
|
Animated.parallel([
|
|
Animated.timing(pauseOverlayOpacity, {
|
|
toValue: 0,
|
|
duration: 220,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(pauseOverlayTranslateY, {
|
|
toValue: 8,
|
|
duration: 220,
|
|
useNativeDriver: true,
|
|
})
|
|
]).start(() => setShowPauseOverlay(false));
|
|
|
|
// Show controls when overlay is touched
|
|
if (!showControls) {
|
|
setShowControls(true);
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 1,
|
|
duration: 300,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
// Auto-hide controls after 5 seconds
|
|
if (controlsTimeout.current) {
|
|
clearTimeout(controlsTimeout.current);
|
|
}
|
|
controlsTimeout.current = setTimeout(hideControls, 5000);
|
|
}
|
|
}
|
|
}, [showPauseOverlay, pauseOverlayOpacity, pauseOverlayTranslateY, showControls, fadeAnim, controlsTimeout, hideControls]);
|
|
|
|
// Handle paused overlay after 5 seconds of being paused
|
|
useEffect(() => {
|
|
if (paused) {
|
|
if (pauseOverlayTimerRef.current) {
|
|
clearTimeout(pauseOverlayTimerRef.current);
|
|
}
|
|
pauseOverlayTimerRef.current = setTimeout(() => {
|
|
setShowPauseOverlay(true);
|
|
pauseOverlayOpacity.setValue(0);
|
|
pauseOverlayTranslateY.setValue(12);
|
|
Animated.parallel([
|
|
Animated.timing(pauseOverlayOpacity, {
|
|
toValue: 1,
|
|
duration: 550,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(pauseOverlayTranslateY, {
|
|
toValue: 0,
|
|
duration: 450,
|
|
useNativeDriver: true,
|
|
})
|
|
]).start();
|
|
}, 5000);
|
|
} else {
|
|
if (pauseOverlayTimerRef.current) {
|
|
clearTimeout(pauseOverlayTimerRef.current);
|
|
pauseOverlayTimerRef.current = null;
|
|
}
|
|
hidePauseOverlay();
|
|
}
|
|
return () => {
|
|
if (pauseOverlayTimerRef.current) {
|
|
clearTimeout(pauseOverlayTimerRef.current);
|
|
pauseOverlayTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [paused]);
|
|
|
|
// Handle next episode button visibility based on current time and next episode availability
|
|
useEffect(() => {
|
|
if (type !== 'series' || !nextEpisode || duration <= 0) {
|
|
if (showNextEpisodeButton) {
|
|
// Hide button with animation
|
|
Animated.parallel([
|
|
Animated.timing(nextEpisodeButtonOpacity, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(nextEpisodeButtonScale, {
|
|
toValue: 0.8,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
})
|
|
]).start(() => {
|
|
setShowNextEpisodeButton(false);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Show button when 1 minute (60 seconds) remains
|
|
const timeRemaining = duration - currentTime;
|
|
const shouldShowButton = timeRemaining <= 60 && timeRemaining > 10; // Hide in last 10 seconds
|
|
|
|
if (shouldShowButton && !showNextEpisodeButton) {
|
|
setShowNextEpisodeButton(true);
|
|
Animated.parallel([
|
|
Animated.timing(nextEpisodeButtonOpacity, {
|
|
toValue: 1,
|
|
duration: 400,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.spring(nextEpisodeButtonScale, {
|
|
toValue: 1,
|
|
tension: 100,
|
|
friction: 8,
|
|
useNativeDriver: true,
|
|
})
|
|
]).start();
|
|
} else if (!shouldShowButton && showNextEpisodeButton) {
|
|
Animated.parallel([
|
|
Animated.timing(nextEpisodeButtonOpacity, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(nextEpisodeButtonScale, {
|
|
toValue: 0.8,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
})
|
|
]).start(() => {
|
|
setShowNextEpisodeButton(false);
|
|
});
|
|
}
|
|
}, [type, nextEpisode, duration, currentTime, showNextEpisodeButton]);
|
|
|
|
useEffect(() => {
|
|
isMounted.current = true;
|
|
return () => {
|
|
isMounted.current = false;
|
|
if (seekDebounceTimer.current) {
|
|
clearTimeout(seekDebounceTimer.current);
|
|
}
|
|
if (errorTimeoutRef.current) {
|
|
clearTimeout(errorTimeoutRef.current);
|
|
}
|
|
if (volumeOverlayTimeout.current) {
|
|
clearTimeout(volumeOverlayTimeout.current);
|
|
}
|
|
if (brightnessOverlayTimeout.current) {
|
|
clearTimeout(brightnessOverlayTimeout.current);
|
|
}
|
|
if (startupRetryTimerRef.current) {
|
|
clearTimeout(startupRetryTimerRef.current);
|
|
startupRetryTimerRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const safeSetState = (setter: any) => {
|
|
if (isMounted.current) {
|
|
setter();
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!useCustomSubtitles || customSubtitles.length === 0) {
|
|
if (currentSubtitle !== '') {
|
|
setCurrentSubtitle('');
|
|
}
|
|
return;
|
|
}
|
|
const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2;
|
|
const currentCue = customSubtitles.find(cue =>
|
|
adjustedTime >= cue.start && adjustedTime <= cue.end
|
|
);
|
|
const newSubtitle = currentCue ? currentCue.text : '';
|
|
setCurrentSubtitle(newSubtitle);
|
|
}, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]);
|
|
|
|
// Load global subtitle settings
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const saved = await storageService.getSubtitleSettings();
|
|
if (saved) {
|
|
if (typeof saved.subtitleSize === 'number') setSubtitleSize(saved.subtitleSize);
|
|
if (typeof saved.subtitleBackground === 'boolean') setSubtitleBackground(saved.subtitleBackground);
|
|
if (typeof saved.subtitleTextColor === 'string') setSubtitleTextColor(saved.subtitleTextColor);
|
|
if (typeof saved.subtitleBgOpacity === 'number') setSubtitleBgOpacity(saved.subtitleBgOpacity);
|
|
if (typeof saved.subtitleTextShadow === 'boolean') setSubtitleTextShadow(saved.subtitleTextShadow);
|
|
if (typeof saved.subtitleOutline === 'boolean') setSubtitleOutline(saved.subtitleOutline);
|
|
if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor);
|
|
if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth);
|
|
if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right');
|
|
if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
|
|
if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing);
|
|
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
|
|
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
|
|
}
|
|
} catch {} finally {
|
|
// Mark subtitle settings as loaded so we can safely persist subsequent changes
|
|
try { setSubtitleSettingsLoaded(true); } catch {}
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
// Persist global subtitle settings on change
|
|
useEffect(() => {
|
|
if (!subtitleSettingsLoaded) return;
|
|
storageService.saveSubtitleSettings({
|
|
subtitleSize,
|
|
subtitleBackground,
|
|
subtitleTextColor,
|
|
subtitleBgOpacity,
|
|
subtitleTextShadow,
|
|
subtitleOutline,
|
|
subtitleOutlineColor,
|
|
subtitleOutlineWidth,
|
|
subtitleAlign,
|
|
subtitleBottomOffset,
|
|
subtitleLetterSpacing,
|
|
subtitleLineHeightMultiplier,
|
|
subtitleOffsetSec,
|
|
});
|
|
}, [
|
|
subtitleSize,
|
|
subtitleBackground,
|
|
subtitleTextColor,
|
|
subtitleBgOpacity,
|
|
subtitleTextShadow,
|
|
subtitleOutline,
|
|
subtitleOutlineColor,
|
|
subtitleOutlineWidth,
|
|
subtitleAlign,
|
|
subtitleBottomOffset,
|
|
subtitleLetterSpacing,
|
|
subtitleLineHeightMultiplier,
|
|
subtitleOffsetSec,
|
|
subtitleSettingsLoaded,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
loadSubtitleSize();
|
|
}, []);
|
|
|
|
// Handle audio track changes with proper logging
|
|
useEffect(() => {
|
|
if (selectedAudioTrack !== null && ksAudioTracks.length > 0) {
|
|
const selectedTrack = ksAudioTracks.find(track => track.id === selectedAudioTrack);
|
|
if (selectedTrack) {
|
|
if (DEBUG_MODE) {
|
|
logger.log(`[VideoPlayer] Audio track selected: ${selectedTrack.name} (${selectedTrack.language}) - ID: ${selectedAudioTrack}`);
|
|
}
|
|
} else {
|
|
logger.warn(`[VideoPlayer] Selected audio track ${selectedAudioTrack} not found in available tracks`);
|
|
}
|
|
}
|
|
}, [selectedAudioTrack, ksAudioTracks]);
|
|
|
|
const increaseSubtitleSize = () => {
|
|
const newSize = Math.min(subtitleSize + 2, 80);
|
|
saveSubtitleSize(newSize);
|
|
};
|
|
|
|
const decreaseSubtitleSize = () => {
|
|
const newSize = Math.max(subtitleSize - 2, 8);
|
|
saveSubtitleSize(newSize);
|
|
};
|
|
|
|
const toggleSubtitleBackground = () => {
|
|
setSubtitleBackground(prev => !prev);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (pendingSeek && isPlayerReady && isVideoLoaded && duration > 0) {
|
|
logger.log(`[VideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`);
|
|
|
|
if (pendingSeek.position > 0) {
|
|
const delayTime = Platform.OS === 'android' ? 1500 : 1000;
|
|
|
|
setTimeout(() => {
|
|
if (duration > 0 && pendingSeek) {
|
|
logger.log(`[VideoPlayer] Executing seek to ${pendingSeek.position}s`);
|
|
|
|
seekToTime(pendingSeek.position);
|
|
|
|
if (pendingSeek.shouldPlay) {
|
|
setTimeout(() => {
|
|
logger.log('[VideoPlayer] Resuming playback after source change seek');
|
|
setPaused(false);
|
|
}, 850); // Delay should be slightly more than seekToTime's internal timeout
|
|
}
|
|
|
|
setTimeout(() => {
|
|
setPendingSeek(null);
|
|
setIsChangingSource(false);
|
|
}, 900);
|
|
}
|
|
}, delayTime);
|
|
} else {
|
|
// No seeking needed, just resume playback if it was playing
|
|
if (pendingSeek.shouldPlay) {
|
|
setTimeout(() => {
|
|
logger.log('[VideoPlayer] No seek needed, just resuming playback');
|
|
setPaused(false);
|
|
}, 500);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
setPendingSeek(null);
|
|
setIsChangingSource(false);
|
|
}, 600);
|
|
}
|
|
}
|
|
}, [pendingSeek, isPlayerReady, isVideoLoaded, duration]);
|
|
|
|
const handleSelectStream = async (newStream: any) => {
|
|
if (newStream.url === currentStreamUrl) {
|
|
setShowSourcesModal(false);
|
|
return;
|
|
}
|
|
|
|
// On iOS: All streams use KSPlayer, no need to switch players
|
|
// Stream switching is handled internally by KSPlayerCore
|
|
|
|
setIsChangingSource(true);
|
|
setShowSourcesModal(false);
|
|
|
|
try {
|
|
// Save current state
|
|
const savedPosition = currentTime;
|
|
const wasPlaying = !paused;
|
|
|
|
logger.log(`[VideoPlayer] Changing source from ${currentStreamUrl} to ${newStream.url}`);
|
|
logger.log(`[VideoPlayer] Saved position: ${savedPosition}, was playing: ${wasPlaying}`);
|
|
|
|
// Extract quality and provider information from the new stream
|
|
let newQuality = newStream.quality;
|
|
if (!newQuality && newStream.title) {
|
|
// Try to extract quality from title (e.g., "1080p", "720p")
|
|
const qualityMatch = newStream.title.match(/(\d+)p/);
|
|
newQuality = qualityMatch ? qualityMatch[0] : undefined; // Use [0] to get full match like "1080p"
|
|
}
|
|
|
|
// For provider, try multiple fields
|
|
const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown';
|
|
|
|
// For stream name, prioritize the stream name over title
|
|
const newStreamName = newStream.name || newStream.title || 'Unknown Stream';
|
|
|
|
logger.log(`[VideoPlayer] Stream object:`, newStream);
|
|
logger.log(`[VideoPlayer] Extracted - Quality: ${newQuality}, Provider: ${newProvider}, Stream Name: ${newStreamName}`);
|
|
logger.log(`[VideoPlayer] Available fields - quality: ${newStream.quality}, title: ${newStream.title}, addonName: ${newStream.addonName}, name: ${newStream.name}, addon: ${newStream.addon}`);
|
|
|
|
// Stop current playback
|
|
setPaused(true);
|
|
|
|
// Set pending seek state
|
|
setPendingSeek({ position: savedPosition, shouldPlay: wasPlaying });
|
|
|
|
// Update the stream URL and details immediately (process URL for KSPlayer)
|
|
setCurrentStreamUrl(processUrlForKsPlayer(newStream.url));
|
|
setCurrentQuality(newQuality);
|
|
setCurrentStreamProvider(newProvider);
|
|
setCurrentStreamName(newStreamName);
|
|
|
|
// Reset player state for new source
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setIsPlayerReady(false);
|
|
setIsVideoLoaded(false);
|
|
|
|
} catch (error) {
|
|
logger.error('[VideoPlayer] Error changing source:', error);
|
|
setPendingSeek(null);
|
|
setIsChangingSource(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isVideoLoaded && initialPosition && !isInitialSeekComplete && duration > 0) {
|
|
logger.log(`[VideoPlayer] Post-load initial seek to: ${initialPosition}s`);
|
|
seekToTime(initialPosition);
|
|
setIsInitialSeekComplete(true);
|
|
// Verify whether the seek actually took effect (detect non-seekable sources)
|
|
if (!initialSeekVerifiedRef.current) {
|
|
initialSeekVerifiedRef.current = true;
|
|
const target = initialSeekTargetRef.current ?? initialPosition;
|
|
setTimeout(() => {
|
|
const delta = Math.abs(currentTime - (target || 0));
|
|
if (target && (currentTime < target - 1.5)) {
|
|
logger.warn(`[VideoPlayer] Initial seek appears ignored (delta=${delta.toFixed(2)}). Treating source as non-seekable; starting from 0`);
|
|
isSourceSeekableRef.current = false;
|
|
// Reset resume intent and continue from 0
|
|
setInitialPosition(null);
|
|
setResumePosition(null);
|
|
setShowResumeOverlay(false);
|
|
} else {
|
|
isSourceSeekableRef.current = true;
|
|
}
|
|
}, 1200);
|
|
}
|
|
}
|
|
}, [isVideoLoaded, initialPosition, duration]);
|
|
|
|
return (
|
|
<View style={[
|
|
styles.container,
|
|
shouldUseFullscreen ? {
|
|
// iPad fullscreen: use flex layout instead of absolute positioning
|
|
flex: 1,
|
|
width: '100%',
|
|
height: '100%',
|
|
} : {
|
|
// iPhone: use absolute positioning with screen dimensions
|
|
width: screenDimensions.width,
|
|
height: screenDimensions.height,
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
}]}>
|
|
{!DISABLE_OPENING_OVERLAY && (
|
|
<Animated.View
|
|
style={[
|
|
styles.openingOverlay,
|
|
{
|
|
opacity: backgroundFadeAnim,
|
|
zIndex: shouldHideOpeningOverlay ? -1 : 3000,
|
|
width: screenDimensions.width,
|
|
height: screenDimensions.height,
|
|
}
|
|
]}
|
|
pointerEvents={shouldHideOpeningOverlay ? 'none' : 'auto'}
|
|
>
|
|
{backdrop && (
|
|
<Animated.View style={[
|
|
StyleSheet.absoluteFill,
|
|
{
|
|
width: screenDimensions.width,
|
|
height: screenDimensions.height,
|
|
opacity: backdropImageOpacityAnim
|
|
}
|
|
]}>
|
|
<FastImage
|
|
source={{ uri: backdrop }}
|
|
style={StyleSheet.absoluteFillObject}
|
|
resizeMode={FastImage.resizeMode.cover}
|
|
/>
|
|
</Animated.View>
|
|
)}
|
|
<LinearGradient
|
|
colors={[
|
|
'rgba(0,0,0,0.3)',
|
|
'rgba(0,0,0,0.6)',
|
|
'rgba(0,0,0,0.8)',
|
|
'rgba(0,0,0,0.9)'
|
|
]}
|
|
locations={[0, 0.3, 0.7, 1]}
|
|
style={StyleSheet.absoluteFill}
|
|
/>
|
|
|
|
<TouchableOpacity
|
|
style={styles.loadingCloseButton}
|
|
onPress={handleClose}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="close" size={24} color="#ffffff" />
|
|
</TouchableOpacity>
|
|
|
|
<View style={styles.openingContent}>
|
|
{hasLogo ? (
|
|
<>
|
|
<Animated.View style={{
|
|
transform: [
|
|
{ scale: Animated.multiply(logoScaleAnim, pulseAnim) }
|
|
],
|
|
opacity: logoOpacityAnim,
|
|
alignItems: 'center',
|
|
}}>
|
|
<FastImage
|
|
source={{ uri: metadata.logo }}
|
|
style={{
|
|
width: 300,
|
|
height: 180,
|
|
}}
|
|
resizeMode={FastImage.resizeMode.contain}
|
|
/>
|
|
</Animated.View>
|
|
</>
|
|
) : (
|
|
<>
|
|
<ActivityIndicator size="large" color="#E50914" />
|
|
</>
|
|
)}
|
|
</View>
|
|
</Animated.View>
|
|
)}
|
|
|
|
{/* Source Change Loading Overlay */}
|
|
{isChangingSource && (
|
|
<Animated.View
|
|
style={[
|
|
styles.sourceChangeOverlay,
|
|
{
|
|
width: screenDimensions.width,
|
|
height: screenDimensions.height,
|
|
opacity: fadeAnim,
|
|
}
|
|
]}
|
|
pointerEvents="auto"
|
|
>
|
|
<View style={styles.sourceChangeContent}>
|
|
<ActivityIndicator size="large" color="#E50914" />
|
|
<Text style={styles.sourceChangeText}>Changing source...</Text>
|
|
<Text style={styles.sourceChangeSubtext}>Please wait while we load the new stream</Text>
|
|
</View>
|
|
</Animated.View>
|
|
)}
|
|
|
|
<Animated.View
|
|
style={[
|
|
styles.videoPlayerContainer,
|
|
{
|
|
opacity: DISABLE_OPENING_OVERLAY ? 1 : openingFadeAnim,
|
|
transform: DISABLE_OPENING_OVERLAY ? [] : [{ scale: openingScaleAnim }],
|
|
width: screenDimensions.width,
|
|
height: screenDimensions.height,
|
|
}
|
|
]}
|
|
>
|
|
{/* Combined gesture handler for left side - brightness + tap */}
|
|
<PanGestureHandler
|
|
onGestureEvent={onBrightnessGestureEvent}
|
|
activeOffsetY={[-5, 5]}
|
|
failOffsetX={[-20, 20]}
|
|
shouldCancelWhenOutside={false}
|
|
simultaneousHandlers={[]}
|
|
maxPointers={1}
|
|
>
|
|
<TapGestureHandler
|
|
onActivated={toggleControls}
|
|
shouldCancelWhenOutside={false}
|
|
simultaneousHandlers={[]}
|
|
>
|
|
<View style={{
|
|
position: 'absolute',
|
|
top: screenDimensions.height * 0.15, // Back to original margin
|
|
left: 0,
|
|
width: screenDimensions.width * 0.4, // Back to larger area (40% of screen)
|
|
height: screenDimensions.height * 0.7, // Back to larger middle portion (70% of screen)
|
|
zIndex: 10, // Higher z-index to capture gestures
|
|
}} />
|
|
</TapGestureHandler>
|
|
</PanGestureHandler>
|
|
|
|
{/* Combined gesture handler for right side - volume + tap */}
|
|
<PanGestureHandler
|
|
onGestureEvent={onVolumeGestureEvent}
|
|
activeOffsetY={[-5, 5]}
|
|
failOffsetX={[-20, 20]}
|
|
shouldCancelWhenOutside={false}
|
|
simultaneousHandlers={[]}
|
|
maxPointers={1}
|
|
>
|
|
<TapGestureHandler
|
|
onActivated={toggleControls}
|
|
shouldCancelWhenOutside={false}
|
|
simultaneousHandlers={[]}
|
|
>
|
|
<View style={{
|
|
position: 'absolute',
|
|
top: screenDimensions.height * 0.15, // Back to original margin
|
|
right: 0,
|
|
width: screenDimensions.width * 0.4, // Back to larger area (40% of screen)
|
|
height: screenDimensions.height * 0.7, // Back to larger middle portion (70% of screen)
|
|
zIndex: 10, // Higher z-index to capture gestures
|
|
}} />
|
|
</TapGestureHandler>
|
|
</PanGestureHandler>
|
|
|
|
{/* Center area tap handler - handles both show and hide */}
|
|
<TapGestureHandler
|
|
onActivated={() => {
|
|
if (showControls) {
|
|
// If controls are visible, hide them
|
|
const timeoutId = setTimeout(() => {
|
|
hideControls();
|
|
}, 0);
|
|
// Clear any existing timeout
|
|
if (controlsTimeout.current) {
|
|
clearTimeout(controlsTimeout.current);
|
|
}
|
|
controlsTimeout.current = timeoutId;
|
|
} else {
|
|
// If controls are hidden, show them
|
|
toggleControls();
|
|
}
|
|
}}
|
|
shouldCancelWhenOutside={false}
|
|
simultaneousHandlers={[]}
|
|
>
|
|
<View style={{
|
|
position: 'absolute',
|
|
top: screenDimensions.height * 0.15,
|
|
left: screenDimensions.width * 0.4, // Start after left gesture area
|
|
width: screenDimensions.width * 0.2, // Center area (20% of screen)
|
|
height: screenDimensions.height * 0.7,
|
|
zIndex: 5, // Lower z-index, controls use box-none to allow touches through
|
|
}} />
|
|
</TapGestureHandler>
|
|
|
|
<View
|
|
style={[styles.videoContainer, {
|
|
width: screenDimensions.width,
|
|
height: screenDimensions.height,
|
|
}]}
|
|
>
|
|
|
|
<PinchGestureHandler
|
|
ref={pinchRef}
|
|
onGestureEvent={onPinchGestureEvent}
|
|
onHandlerStateChange={onPinchHandlerStateChange}
|
|
>
|
|
<View style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: screenDimensions.width,
|
|
height: screenDimensions.height,
|
|
}}>
|
|
<TouchableOpacity
|
|
style={{ flex: 1 }}
|
|
activeOpacity={1}
|
|
onPress={toggleControls}
|
|
onLongPress={resetZoom}
|
|
delayLongPress={300}
|
|
>
|
|
<KSPlayerComponent
|
|
ref={ksPlayerRef}
|
|
style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]}
|
|
source={{
|
|
uri: currentStreamUrl,
|
|
headers: headers && Object.keys(headers).length > 0 ? headers : undefined
|
|
}}
|
|
paused={paused}
|
|
volume={volume / 100}
|
|
audioTrack={selectedAudioTrack ?? undefined}
|
|
textTrack={useCustomSubtitles ? -1 : selectedTextTrack}
|
|
onProgress={handleProgress}
|
|
onLoad={onLoad}
|
|
onEnd={onEnd}
|
|
onError={handleError}
|
|
onBuffering={onBuffering}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</PinchGestureHandler>
|
|
|
|
<PlayerControls
|
|
showControls={showControls}
|
|
fadeAnim={fadeAnim}
|
|
paused={paused}
|
|
title={title}
|
|
episodeTitle={episodeTitle}
|
|
season={season}
|
|
episode={episode}
|
|
quality={currentQuality || quality}
|
|
year={year}
|
|
streamProvider={currentStreamProvider || streamProvider}
|
|
streamName={currentStreamName}
|
|
currentTime={currentTime}
|
|
duration={duration}
|
|
zoomScale={zoomScale}
|
|
ksAudioTracks={ksAudioTracks}
|
|
selectedAudioTrack={selectedAudioTrack}
|
|
availableStreams={availableStreams}
|
|
togglePlayback={togglePlayback}
|
|
skip={skip}
|
|
handleClose={handleClose}
|
|
cycleAspectRatio={cycleAspectRatio}
|
|
setShowAudioModal={setShowAudioModal}
|
|
setShowSubtitleModal={setShowSubtitleModal}
|
|
isSubtitleModalOpen={showSubtitleModal}
|
|
setShowSourcesModal={setShowSourcesModal}
|
|
onSliderValueChange={handleSliderValueChange}
|
|
onSlidingStart={handleSlidingStart}
|
|
onSlidingComplete={handleSlidingComplete}
|
|
buffered={buffered}
|
|
formatTime={formatTime}
|
|
cyclePlaybackSpeed={cyclePlaybackSpeed}
|
|
currentPlaybackSpeed={playbackSpeed}
|
|
/>
|
|
|
|
{showPauseOverlay && (
|
|
<TouchableOpacity
|
|
activeOpacity={1}
|
|
onPress={hidePauseOverlay}
|
|
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,
|
|
}}
|
|
>
|
|
{/* Strong horizontal fade from left side */}
|
|
<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 ? (
|
|
// Cast Detail View with fade transition
|
|
<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={() => {
|
|
// Animate cast details out, then metadata back in
|
|
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);
|
|
// Animate metadata back in
|
|
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), // Proper aspect ratio 2:3
|
|
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,
|
|
lineHeight: Math.min(38, screenDimensions.width * 0.05)
|
|
}} 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>
|
|
)}
|
|
|
|
{/* Biography if available */}
|
|
{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>
|
|
) : (
|
|
// Default Metadata 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>
|
|
)}
|
|
{(currentEpisodeDescription || metadata?.description) && (
|
|
<Text style={{
|
|
color: '#D6D6D6',
|
|
fontSize: Math.min(18, screenDimensions.width * 0.025),
|
|
lineHeight: Math.min(24, screenDimensions.width * 0.03)
|
|
}} numberOfLines={3}>
|
|
{type === 'series' ? (currentEpisodeDescription || metadata?.description || '') : (metadata?.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);
|
|
// Animate metadata out, then cast details in
|
|
Animated.parallel([
|
|
Animated.timing(metadataOpacity, {
|
|
toValue: 0,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(metadataScale, {
|
|
toValue: 0.95,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
})
|
|
]).start(() => {
|
|
setShowCastDetails(true);
|
|
// Animate cast details in
|
|
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>
|
|
)}
|
|
|
|
{/* Next Episode Button */}
|
|
{showNextEpisodeButton && nextEpisode && (
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: 80 + insets.bottom,
|
|
right: 8 + insets.right,
|
|
opacity: nextEpisodeButtonOpacity,
|
|
transform: [{ scale: nextEpisodeButtonScale }],
|
|
zIndex: 50,
|
|
}}
|
|
>
|
|
<TouchableOpacity
|
|
style={{
|
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
|
borderRadius: 18,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 8,
|
|
elevation: 8,
|
|
}}
|
|
onPress={handlePlayNextEpisode}
|
|
activeOpacity={0.8}
|
|
>
|
|
{isLoadingNextEpisode ? (
|
|
<ActivityIndicator size="small" color="#000000" style={{ marginRight: 8 }} />
|
|
) : (
|
|
<MaterialIcons name="skip-next" size={18} color="#000000" style={{ marginRight: 8 }} />
|
|
)}
|
|
<View>
|
|
<Text style={{ color: '#000000', fontSize: 11, fontWeight: '700', opacity: 0.8 }}>
|
|
{isLoadingNextEpisode ? 'Loading next episode…' : 'Up next'}
|
|
</Text>
|
|
<Text style={{ color: '#000000', fontSize: 13, fontWeight: '700' }} numberOfLines={1}>
|
|
S{nextEpisode.season_number}E{nextEpisode.episode_number}
|
|
{nextEpisode.name ? `: ${nextEpisode.name}` : ''}
|
|
</Text>
|
|
{isLoadingNextEpisode && (
|
|
<Text style={{ color: '#333333', fontSize: 11, marginTop: 2 }} numberOfLines={1}>
|
|
{nextLoadingProvider ? `${nextLoadingProvider}` : 'Finding source…'}
|
|
{nextLoadingQuality ? ` • ${nextLoadingQuality}p` : ''}
|
|
{nextLoadingTitle ? ` • ${nextLoadingTitle}` : ''}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
)}
|
|
|
|
<CustomSubtitles
|
|
useCustomSubtitles={useCustomSubtitles}
|
|
currentSubtitle={currentSubtitle}
|
|
subtitleSize={subtitleSize}
|
|
subtitleBackground={subtitleBackground}
|
|
zoomScale={zoomScale}
|
|
textColor={subtitleTextColor}
|
|
backgroundOpacity={subtitleBgOpacity}
|
|
textShadow={subtitleTextShadow}
|
|
outline={subtitleOutline}
|
|
outlineColor={subtitleOutlineColor}
|
|
outlineWidth={subtitleOutlineWidth}
|
|
align={subtitleAlign}
|
|
bottomOffset={subtitleBottomOffset}
|
|
letterSpacing={subtitleLetterSpacing}
|
|
lineHeightMultiplier={subtitleLineHeightMultiplier}
|
|
controlsVisible={showControls}
|
|
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 126 : 106}
|
|
/>
|
|
|
|
{/* Volume Overlay */}
|
|
{showVolumeOverlay && (
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
left: screenDimensions.width / 2 - 60,
|
|
top: screenDimensions.height / 2 - 60,
|
|
opacity: 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 */}
|
|
{showBrightnessOverlay && (
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
left: screenDimensions.width / 2 - 60,
|
|
top: screenDimensions.height / 2 - 60,
|
|
opacity: 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>
|
|
)}
|
|
|
|
|
|
{/* Resume overlay removed when AlwaysResume is enabled; overlay component omitted */}
|
|
</View>
|
|
</Animated.View>
|
|
|
|
<AudioTrackModal
|
|
showAudioModal={showAudioModal}
|
|
setShowAudioModal={setShowAudioModal}
|
|
ksAudioTracks={ksAudioTracks}
|
|
selectedAudioTrack={selectedAudioTrack}
|
|
selectAudioTrack={selectAudioTrack}
|
|
/>
|
|
<SubtitleModals
|
|
showSubtitleModal={showSubtitleModal}
|
|
setShowSubtitleModal={setShowSubtitleModal}
|
|
showSubtitleLanguageModal={showSubtitleLanguageModal}
|
|
setShowSubtitleLanguageModal={setShowSubtitleLanguageModal}
|
|
isLoadingSubtitleList={isLoadingSubtitleList}
|
|
isLoadingSubtitles={isLoadingSubtitles}
|
|
customSubtitles={customSubtitles}
|
|
availableSubtitles={availableSubtitles}
|
|
ksTextTracks={ksTextTracks}
|
|
selectedTextTrack={selectedTextTrack}
|
|
useCustomSubtitles={useCustomSubtitles}
|
|
isKsPlayerActive={isKsPlayerActive}
|
|
subtitleSize={subtitleSize}
|
|
subtitleBackground={subtitleBackground}
|
|
fetchAvailableSubtitles={fetchAvailableSubtitles}
|
|
loadWyzieSubtitle={loadWyzieSubtitle}
|
|
selectTextTrack={selectTextTrack}
|
|
disableCustomSubtitles={disableCustomSubtitles}
|
|
increaseSubtitleSize={increaseSubtitleSize}
|
|
decreaseSubtitleSize={decreaseSubtitleSize}
|
|
toggleSubtitleBackground={toggleSubtitleBackground}
|
|
subtitleTextColor={subtitleTextColor}
|
|
setSubtitleTextColor={setSubtitleTextColor}
|
|
subtitleBgOpacity={subtitleBgOpacity}
|
|
setSubtitleBgOpacity={setSubtitleBgOpacity}
|
|
subtitleTextShadow={subtitleTextShadow}
|
|
setSubtitleTextShadow={setSubtitleTextShadow}
|
|
subtitleOutline={subtitleOutline}
|
|
setSubtitleOutline={setSubtitleOutline}
|
|
subtitleOutlineColor={subtitleOutlineColor}
|
|
setSubtitleOutlineColor={setSubtitleOutlineColor}
|
|
subtitleOutlineWidth={subtitleOutlineWidth}
|
|
setSubtitleOutlineWidth={setSubtitleOutlineWidth}
|
|
subtitleAlign={subtitleAlign}
|
|
setSubtitleAlign={setSubtitleAlign}
|
|
subtitleBottomOffset={subtitleBottomOffset}
|
|
setSubtitleBottomOffset={setSubtitleBottomOffset}
|
|
subtitleLetterSpacing={subtitleLetterSpacing}
|
|
setSubtitleLetterSpacing={setSubtitleLetterSpacing}
|
|
subtitleLineHeightMultiplier={subtitleLineHeightMultiplier}
|
|
setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier}
|
|
subtitleOffsetSec={subtitleOffsetSec}
|
|
setSubtitleOffsetSec={setSubtitleOffsetSec}
|
|
/>
|
|
|
|
<SourcesModal
|
|
showSourcesModal={showSourcesModal}
|
|
setShowSourcesModal={setShowSourcesModal}
|
|
availableStreams={availableStreams}
|
|
currentStreamUrl={currentStreamUrl}
|
|
onSelectStream={handleSelectStream}
|
|
isChangingSource={isChangingSource}
|
|
/>
|
|
|
|
{/* Error Modal */}
|
|
<Modal
|
|
visible={showErrorModal}
|
|
transparent
|
|
animationType="fade"
|
|
supportedOrientations={["landscape", "landscape-left", "landscape-right", "portrait"]}
|
|
onRequestClose={handleErrorExit}
|
|
>
|
|
<View style={{
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: 'rgba(0,0,0,0.8)'
|
|
}}>
|
|
<View style={{
|
|
backgroundColor: '#1a1a1a',
|
|
borderRadius: 14,
|
|
width: '85%',
|
|
maxHeight: '70%',
|
|
padding: 20,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 6 },
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 8,
|
|
elevation: 5,
|
|
}}>
|
|
<View style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginBottom: 16
|
|
}}>
|
|
<MaterialIcons name="error" size={24} color="#ff4444" style={{ marginRight: 8 }} />
|
|
<Text style={{
|
|
fontSize: 18,
|
|
fontWeight: 'bold',
|
|
color: '#ffffff',
|
|
flex: 1
|
|
}}>Playback Error</Text>
|
|
<TouchableOpacity onPress={handleErrorExit}>
|
|
<MaterialIcons name="close" size={24} color="#ffffff" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<Text style={{
|
|
fontSize: 14,
|
|
color: '#cccccc',
|
|
marginBottom: 16,
|
|
lineHeight: 20
|
|
}}>The video player encountered an error and cannot continue playback:</Text>
|
|
|
|
<View style={{
|
|
backgroundColor: '#2a2a2a',
|
|
borderRadius: 8,
|
|
padding: 12,
|
|
marginBottom: 20,
|
|
maxHeight: 200
|
|
}}>
|
|
<Text style={{
|
|
fontSize: 12,
|
|
color: '#ff8888',
|
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace'
|
|
}}>{errorDetails}</Text>
|
|
</View>
|
|
|
|
<View style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end'
|
|
}}>
|
|
<TouchableOpacity
|
|
style={{
|
|
backgroundColor: '#ff4444',
|
|
borderRadius: 8,
|
|
paddingVertical: 10,
|
|
paddingHorizontal: 20
|
|
}}
|
|
onPress={handleErrorExit}
|
|
>
|
|
<Text style={{
|
|
color: '#ffffff',
|
|
fontWeight: '600',
|
|
fontSize: 16
|
|
}}>Exit Player</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<Text style={{
|
|
fontSize: 12,
|
|
color: '#888888',
|
|
textAlign: 'center',
|
|
marginTop: 12
|
|
}}>This dialog will auto-close in 5 seconds</Text>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default KSPlayerCore; |