mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Compare commits
11 commits
49c3c6a693
...
e32fe35028
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32fe35028 | ||
|
|
7d60a0c43f | ||
|
|
e6c7bfc895 | ||
|
|
911730770b | ||
|
|
66b38c483b | ||
|
|
cf62ae74e6 | ||
|
|
51706e1478 | ||
|
|
5bbc2abe8b | ||
|
|
91d81d53c1 | ||
|
|
649fc78a1d | ||
|
|
03bea009b0 |
14 changed files with 299 additions and 80 deletions
|
|
@ -23,7 +23,7 @@
|
|||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ERROR_RECOVERY_ONLY"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="5000"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.nuvioapp.space/api/manifest"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|locale|layoutDirection" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode|locale|layoutDirection" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified" android:supportsPictureInPicture="true" android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
|
|
|
|||
|
|
@ -391,7 +391,7 @@ index e16ac96..54221ef 100644
|
|||
+ && activity.isInPictureInPictureMode();
|
||||
+ boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null
|
||||
+ && activity.isInMultiWindowMode();
|
||||
if (playInBackground || isInPictureInPicture || isInMultiWindowMode) {
|
||||
if (playInBackground || isInPictureInPicture || isInMultiWindowMode || enterPictureInPictureOnLeave) {
|
||||
return;
|
||||
}
|
||||
@@ -403,7 +407,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { View, StyleSheet, Platform, Animated, ToastAndroid, ActivityIndicator } from 'react-native';
|
||||
import { View, StyleSheet, Platform, Animated, ToastAndroid, ActivityIndicator, AppState } from 'react-native';
|
||||
import { toast } from '@backpackapp-io/react-native-toast';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
|
|
@ -102,6 +102,12 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// State to force unmount VideoSurface during stream transitions
|
||||
const [isTransitioningStream, setIsTransitioningStream] = useState(false);
|
||||
|
||||
const supportsPictureInPicture = Platform.OS === 'android' && Number(Platform.Version) >= 26;
|
||||
const [isInPictureInPicture, setIsInPictureInPicture] = useState(false);
|
||||
const [isPiPTransitionPending, setIsPiPTransitionPending] = useState(false);
|
||||
const pipSupportLoggedRef = useRef<boolean | null>(null);
|
||||
const pipAutoEntryStateRef = useRef<string>('');
|
||||
|
||||
// Dual video engine state: ExoPlayer primary, MPV fallback
|
||||
// If videoPlayerEngine is 'mpv', always use MPV; otherwise use auto behavior
|
||||
const shouldUseMpvOnly = settings.videoPlayerEngine === 'mpv';
|
||||
|
|
@ -120,6 +126,16 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
}, [settings.videoPlayerEngine]);
|
||||
|
||||
const autoEnterPipReason = useMemo(() => {
|
||||
if (!supportsPictureInPicture) return 'unsupported_platform_or_api';
|
||||
if (!useExoPlayer) return 'engine_mpv';
|
||||
if (playerState.paused) return 'paused';
|
||||
return 'enabled';
|
||||
}, [supportsPictureInPicture, useExoPlayer, playerState.paused]);
|
||||
|
||||
const shouldAutoEnterPip = autoEnterPipReason === 'enabled';
|
||||
const canShowPipButton = supportsPictureInPicture && useExoPlayer;
|
||||
|
||||
// Subtitle addon state
|
||||
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
|
||||
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false);
|
||||
|
|
@ -210,7 +226,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
playerState.paused,
|
||||
traktAutosync,
|
||||
controlsHook.seekToTime,
|
||||
currentStreamProvider
|
||||
currentStreamProvider,
|
||||
isInPictureInPicture || isPiPTransitionPending
|
||||
);
|
||||
|
||||
const gestureControls = usePlayerGestureControls({
|
||||
|
|
@ -521,6 +538,77 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any);
|
||||
}, [navigation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pipSupportLoggedRef.current === supportsPictureInPicture) return;
|
||||
pipSupportLoggedRef.current = supportsPictureInPicture;
|
||||
logger.info(`[PiP] Support ${supportsPictureInPicture ? 'enabled' : 'disabled'} (api=${String(Platform.Version)})`);
|
||||
}, [supportsPictureInPicture]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pipAutoEntryStateRef.current === autoEnterPipReason) return;
|
||||
pipAutoEntryStateRef.current = autoEnterPipReason;
|
||||
if (autoEnterPipReason === 'enabled') {
|
||||
logger.info('[PiP] Auto-entry enabled');
|
||||
} else {
|
||||
logger.info(`[PiP] Auto-entry disabled (${autoEnterPipReason})`);
|
||||
}
|
||||
}, [autoEnterPipReason]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState.match(/inactive|background/) && shouldAutoEnterPip) {
|
||||
logger.info('[PiP] Background transition detected; waiting for PiP status callback');
|
||||
setIsPiPTransitionPending(true);
|
||||
}
|
||||
if (nextAppState === 'active') {
|
||||
setIsPiPTransitionPending(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [shouldAutoEnterPip]);
|
||||
|
||||
const handlePictureInPictureStatusChanged = useCallback((isInPip: boolean) => {
|
||||
setIsInPictureInPicture((previous) => {
|
||||
if (previous !== isInPip) {
|
||||
logger.info(`[PiP] Status changed: ${isInPip ? 'entered' : 'exited'}`);
|
||||
}
|
||||
return isInPip;
|
||||
});
|
||||
if (isInPip) {
|
||||
setIsPiPTransitionPending(false);
|
||||
playerState.setShowControls(false);
|
||||
} else {
|
||||
setIsPiPTransitionPending(false);
|
||||
}
|
||||
}, [playerState.setShowControls]);
|
||||
|
||||
const handleEnterPictureInPicture = useCallback(() => {
|
||||
if (!supportsPictureInPicture) {
|
||||
logger.info('[PiP] Manual entry skipped: unsupported platform/API');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!useExoPlayer) {
|
||||
logger.info('[PiP] Manual entry blocked: MPV backend active');
|
||||
ToastAndroid.show('PiP currently works with ExoPlayer only', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerRef = exoPlayerRef.current as any;
|
||||
const enterPiPMethod = playerRef?.enterPictureInPicture ?? playerRef?.enterPictureInPictureMode;
|
||||
if (typeof enterPiPMethod !== 'function') {
|
||||
logger.warn('[PiP] Manual entry unavailable: Exo ref has no PiP method');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[PiP] Manual entry requested');
|
||||
setIsPiPTransitionPending(true);
|
||||
enterPiPMethod.call(playerRef);
|
||||
}, [supportsPictureInPicture, useExoPlayer]);
|
||||
|
||||
// Handle codec errors from ExoPlayer - silently switch to MPV
|
||||
const handleCodecError = useCallback(() => {
|
||||
if (!hasExoPlayerFailed.current) {
|
||||
|
|
@ -841,6 +929,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Dual video engine props
|
||||
useExoPlayer={useExoPlayer}
|
||||
onCodecError={handleCodecError}
|
||||
enterPictureInPictureOnLeave={shouldAutoEnterPip}
|
||||
onPictureInPictureStatusChanged={handlePictureInPictureStatusChanged}
|
||||
selectedAudioTrack={tracksHook.selectedAudioTrack as any || undefined}
|
||||
selectedTextTrack={memoizedSelectedTextTrack as any}
|
||||
// Subtitle Styling - pass to MPV for built-in subtitle customization
|
||||
|
|
@ -957,6 +1047,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
|
||||
onSwitchToMPV={handleManualSwitchToMPV}
|
||||
useExoPlayer={useExoPlayer}
|
||||
canEnterPictureInPicture={canShowPipButton}
|
||||
onEnterPictureInPicture={handleEnterPictureInPicture}
|
||||
isBuffering={playerState.isBuffering}
|
||||
imdbId={imdbId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ interface VideoSurfaceProps {
|
|||
useExoPlayer?: boolean;
|
||||
onCodecError?: () => void;
|
||||
onEngineChange?: (engine: 'exoplayer' | 'mpv') => void;
|
||||
enterPictureInPictureOnLeave?: boolean;
|
||||
onPictureInPictureStatusChanged?: (isInPip: boolean) => void;
|
||||
|
||||
// Subtitle Styling
|
||||
subtitleSize?: number;
|
||||
|
|
@ -244,6 +246,8 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
useExoPlayer = true,
|
||||
onCodecError,
|
||||
onEngineChange,
|
||||
enterPictureInPictureOnLeave = false,
|
||||
onPictureInPictureStatusChanged,
|
||||
// Subtitle Styling
|
||||
subtitleSize,
|
||||
subtitleColor,
|
||||
|
|
@ -318,6 +322,14 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
console.log('[VideoSurface] Headers:', exoRequestHeaders);
|
||||
}, [streamUrl, useExoPlayer, exoRequestHeaders]);
|
||||
|
||||
const lastPipAutoEnterStateRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (!useExoPlayer) return;
|
||||
if (lastPipAutoEnterStateRef.current === enterPictureInPictureOnLeave) return;
|
||||
lastPipAutoEnterStateRef.current = enterPictureInPictureOnLeave;
|
||||
logger.info(`[PiP] VideoSurface auto-enter-on-leave ${enterPictureInPictureOnLeave ? 'enabled' : 'disabled'}`);
|
||||
}, [useExoPlayer, enterPictureInPictureOnLeave]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mpvPlayerRef?.current && !useExoPlayer) {
|
||||
mpvPlayerRef.current.setResizeMode(getMpvResizeMode());
|
||||
|
|
@ -429,6 +441,20 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
onSeek({ currentTime: data.currentTime });
|
||||
};
|
||||
|
||||
const handleExoPictureInPictureStatusChanged = (event: any) => {
|
||||
const isInPictureInPicture = typeof event === 'boolean'
|
||||
? event
|
||||
: Boolean(
|
||||
event?.isInPictureInPicture
|
||||
?? event?.isActive
|
||||
?? event?.nativeEvent?.isInPictureInPicture
|
||||
?? event?.nativeEvent?.isActive
|
||||
?? event?.value
|
||||
);
|
||||
logger.info(`[PiP] VideoSurface status event: ${isInPictureInPicture ? 'entered' : 'exited'}`);
|
||||
onPictureInPictureStatusChanged?.(isInPictureInPicture);
|
||||
};
|
||||
|
||||
const getExoResizeMode = (): ResizeMode => {
|
||||
switch (resizeMode) {
|
||||
case 'cover':
|
||||
|
|
@ -502,6 +528,10 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
playInBackground={false}
|
||||
playWhenInactive={false}
|
||||
ignoreSilentSwitch="ignore"
|
||||
// @ts-ignore - Prop supported by patched react-native-video
|
||||
enterPictureInPictureOnLeave={enterPictureInPictureOnLeave}
|
||||
// @ts-ignore - Prop supported by patched react-native-video
|
||||
onPictureInPictureStatusChanged={handleExoPictureInPictureStatusChanged}
|
||||
automaticallyWaitsToMinimizeStalling={true}
|
||||
useTextureView={true}
|
||||
subtitleStyle={{
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ interface PlayerControlsProps {
|
|||
// MPV Switch (Android only)
|
||||
onSwitchToMPV?: () => void;
|
||||
useExoPlayer?: boolean;
|
||||
canEnterPictureInPicture?: boolean;
|
||||
onEnterPictureInPicture?: () => void;
|
||||
isBuffering?: boolean;
|
||||
imdbId?: string;
|
||||
}
|
||||
|
|
@ -114,6 +116,8 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
onAirPlayPress,
|
||||
onSwitchToMPV,
|
||||
useExoPlayer,
|
||||
canEnterPictureInPicture,
|
||||
onEnterPictureInPicture,
|
||||
isBuffering = false,
|
||||
imdbId,
|
||||
}) => {
|
||||
|
|
@ -399,6 +403,18 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{Platform.OS === 'android' && canEnterPictureInPicture && onEnterPictureInPicture && (
|
||||
<TouchableOpacity
|
||||
style={{ padding: 8 }}
|
||||
onPress={onEnterPictureInPicture}
|
||||
>
|
||||
<Feather
|
||||
name="minimize-2"
|
||||
size={closeIconSize}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
|
||||
<Ionicons name="close" size={closeIconSize} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ export const useWatchProgress = (
|
|||
paused: boolean,
|
||||
traktAutosync: any,
|
||||
seekToTime: (time: number) => void,
|
||||
addonId?: string
|
||||
addonId?: string,
|
||||
isInPictureInPicture: boolean = false
|
||||
) => {
|
||||
const [resumePosition, setResumePosition] = useState<number | null>(null);
|
||||
const [savedDuration, setSavedDuration] = useState<number | null>(null);
|
||||
|
|
@ -26,6 +27,7 @@ export const useWatchProgress = (
|
|||
// Values refs for unmount cleanup
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
const durationRef = useRef(duration);
|
||||
const isInPictureInPictureRef = useRef(isInPictureInPicture);
|
||||
|
||||
useEffect(() => {
|
||||
currentTimeRef.current = currentTime;
|
||||
|
|
@ -35,6 +37,10 @@ export const useWatchProgress = (
|
|||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
useEffect(() => {
|
||||
isInPictureInPictureRef.current = isInPictureInPicture;
|
||||
}, [isInPictureInPicture]);
|
||||
|
||||
// Keep latest traktAutosync ref to avoid dependency cycles in listeners
|
||||
const traktAutosyncRef = useRef(traktAutosync);
|
||||
useEffect(() => {
|
||||
|
|
@ -58,9 +64,13 @@ export const useWatchProgress = (
|
|||
try {
|
||||
await storageService.setWatchProgress(id, type, progress, episodeId);
|
||||
|
||||
// Trakt sync (end session)
|
||||
// Use 'user_close' to force immediate sync
|
||||
await traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close');
|
||||
if (isInPictureInPictureRef.current) {
|
||||
logger.log('[useWatchProgress] In PiP mode, skipping background playback end sync');
|
||||
} else {
|
||||
// Trakt sync (end session)
|
||||
// Use 'user_close' to force immediate sync
|
||||
await traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[useWatchProgress] Error saving background progress:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1877,6 +1877,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
}
|
||||
|
||||
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
|
||||
const isImdb = id.startsWith('tt');
|
||||
if (__DEV__) console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
|
||||
let tmdbId;
|
||||
let stremioEpisodeId = episodeId; // Default to original episode ID
|
||||
|
|
@ -1901,19 +1902,25 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
const cleanEpisodeId = episodeId.replace(/^series:/, '');
|
||||
const parts = cleanEpisodeId.split(':');
|
||||
|
||||
if (parts[0] === 'kitsu' && parts.length === 3) {
|
||||
// kitsu:animeId:episode — no season segment
|
||||
if (isImdb && parts.length === 3) {
|
||||
// Format: ttXXX:season:episode
|
||||
showIdStr = parts[0];
|
||||
seasonNum = parts[1];
|
||||
episodeNum = parts[2];
|
||||
} else if (!isImdb && parts.length === 3) {
|
||||
// Format: prefix:id:episode (no season for MAL/Kitsu/etc)
|
||||
showIdStr = `${parts[0]}:${parts[1]}`;
|
||||
episodeNum = parts[2];
|
||||
seasonNum = '';
|
||||
} else if (parts.length >= 3) {
|
||||
episodeNum = parts.pop() || '';
|
||||
seasonNum = parts.pop() || '';
|
||||
showIdStr = parts.join(':');
|
||||
} else if (parts.length === 2) {
|
||||
showIdStr = parts[0];
|
||||
episodeNum = parts[1];
|
||||
seasonNum = '';
|
||||
} else if (parts.length >= 4) {
|
||||
// Format: prefix:id:season:episode - it is possible that some addons use it
|
||||
episodeNum = parts.pop() || '';
|
||||
seasonNum = parts.pop() || '';
|
||||
showIdStr = parts.join(':');
|
||||
}
|
||||
|
||||
if (__DEV__) console.log(`🔍 [loadEpisodeStreams] Parsed ID: show=${showIdStr}, s=${seasonNum}, e=${episodeNum}`);
|
||||
|
|
@ -1976,7 +1983,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
if (__DEV__) console.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using TMDB episode ID:', error);
|
||||
}
|
||||
}
|
||||
} else if (id.startsWith('tt')) {
|
||||
} else if (isImdb) {
|
||||
// This is already an IMDB ID, perfect for Stremio
|
||||
if (settings.enrichMetadataWithTMDB) {
|
||||
if (__DEV__) console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
|
||||
|
|
|
|||
|
|
@ -3,30 +3,36 @@ import { LanguageDetectorAsyncModule } from 'i18next';
|
|||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
|
||||
const languageDetector: LanguageDetectorAsyncModule = {
|
||||
type: 'languageDetector',
|
||||
async: true,
|
||||
detect: (callback: (lng: string | undefined) => void): void => {
|
||||
const findLanguage = async () => {
|
||||
try {
|
||||
const savedLanguage = await mmkvStorage.getItem('user_language');
|
||||
if (savedLanguage) {
|
||||
callback(savedLanguage);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error reading language from storage', error);
|
||||
}
|
||||
type: "languageDetector",
|
||||
async: true,
|
||||
detect: (callback: (lng: string | undefined) => void): void => {
|
||||
const findLanguage = async () => {
|
||||
try {
|
||||
const savedLanguage = await mmkvStorage.getItem("user_language");
|
||||
if (savedLanguage) {
|
||||
callback(savedLanguage);
|
||||
return;
|
||||
}
|
||||
|
||||
const locales = getLocales();
|
||||
const languageCode = locales[0]?.languageCode ?? 'en';
|
||||
callback(languageCode);
|
||||
};
|
||||
findLanguage();
|
||||
},
|
||||
init: () => { },
|
||||
cacheUserLanguage: (language: string) => {
|
||||
mmkvStorage.setItem('user_language', language);
|
||||
},
|
||||
const locales = getLocales();
|
||||
if (!locales || locales.length === 0) {
|
||||
callback("en");
|
||||
return;
|
||||
}
|
||||
|
||||
const bestTag = locales[0].languageTag;
|
||||
callback(bestTag);
|
||||
} catch (error) {
|
||||
console.error("[LangDetector(TEST)] Failed to detect language:", error);
|
||||
callback("en");
|
||||
}
|
||||
};
|
||||
findLanguage();
|
||||
},
|
||||
init: () => {},
|
||||
cacheUserLanguage: (language: string) => {
|
||||
mmkvStorage.setItem("user_language", language);
|
||||
},
|
||||
};
|
||||
|
||||
export default languageDetector;
|
||||
export default languageDetector;
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Platform,
|
||||
Clipboard,
|
||||
Linking,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
|
|
@ -657,12 +658,17 @@ const DownloadsScreen: React.FC = () => {
|
|||
isTablet={isTablet}
|
||||
>
|
||||
{downloads.length > 0 && (
|
||||
<View style={styles.filterContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScrollContainer}
|
||||
contentContainerStyle={styles.filterContainer}
|
||||
>
|
||||
{renderFilterButton('all', t('downloads.filter_all'), stats.total)}
|
||||
{renderFilterButton('downloading', t('downloads.filter_active'), stats.downloading)}
|
||||
{renderFilterButton('completed', t('downloads.filter_done'), stats.completed)}
|
||||
{renderFilterButton('paused', t('downloads.filter_paused'), stats.paused)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</ScreenHeader>
|
||||
|
||||
|
|
@ -745,9 +751,14 @@ const styles = StyleSheet.create({
|
|||
padding: 8,
|
||||
marginLeft: 8,
|
||||
},
|
||||
// testing add scroll
|
||||
filterScrollContainer: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: isTablet ? 16 : 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
filterButton: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -756,6 +767,8 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: isTablet ? 10 : 8,
|
||||
borderRadius: 20,
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
minWidth: 'auto',
|
||||
},
|
||||
filterButtonText: {
|
||||
fontSize: isTablet ? 16 : 14,
|
||||
|
|
|
|||
|
|
@ -586,13 +586,17 @@ const MetadataScreen: React.FC = () => {
|
|||
|
||||
const handleShowStreams = useCallback(() => {
|
||||
const { watchProgress } = watchProgressData;
|
||||
const isImdb = id.startsWith('tt');
|
||||
|
||||
// Ensure trailer stops immediately before navigating to Streams
|
||||
try { pauseTrailer(); } catch { }
|
||||
|
||||
// Helper to build episodeId from episode object
|
||||
const buildEpisodeId = (ep: any): string => {
|
||||
return ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
|
||||
if (ep.stremioId) return ep.stremioId;
|
||||
return isImdb
|
||||
? `${id}:${ep.season_number}:${ep.episode_number}`
|
||||
: `${id}:${ep.episode_number}`;
|
||||
};
|
||||
|
||||
if (Object.keys(groupedEpisodes).length > 0) {
|
||||
|
|
@ -611,38 +615,28 @@ const MetadataScreen: React.FC = () => {
|
|||
|
||||
const parts = watchProgress.episodeId.split(':');
|
||||
|
||||
if (parts.length === 3) {
|
||||
// showId:season:episode
|
||||
currentSeason = parseInt(parts[1], 10);
|
||||
currentEpisode = parseInt(parts[2], 10);
|
||||
} else if (parts.length === 2) {
|
||||
// season:episode
|
||||
currentSeason = parseInt(parts[0], 10);
|
||||
currentEpisode = parseInt(parts[1], 10);
|
||||
} else {
|
||||
// pattern like s5e01
|
||||
const match = watchProgress.episodeId.match(/s(\d+)e(\d+)/i);
|
||||
if (match) {
|
||||
currentSeason = parseInt(match[1], 10);
|
||||
currentEpisode = parseInt(match[2], 10);
|
||||
if (isImdb) {
|
||||
if (parts.length === 3) {
|
||||
currentSeason = parseInt(parts[1], 10);
|
||||
currentEpisode = parseInt(parts[2], 10);
|
||||
} else if (parts.length === 2) {
|
||||
currentEpisode = parseInt(parts[1], 10);
|
||||
}
|
||||
} else {
|
||||
currentEpisode = parts.length === 3 ? parseInt(parts[2], 10) : null;
|
||||
}
|
||||
|
||||
if (currentSeason !== null && currentEpisode !== null) {
|
||||
// DIRECT APPROACH: Just create the next episode ID directly
|
||||
// This ensures we navigate to the next episode even if it's not yet in our episodes array
|
||||
const nextEpisodeId = `${id}:${currentSeason}:${currentEpisode + 1}`;
|
||||
if (__DEV__) console.log(`[MetadataScreen] Created next episode ID directly: ${nextEpisodeId}`);
|
||||
|
||||
// Still try to find the episode in our list to verify it exists
|
||||
const nextEpisodeExists = episodes.some(ep =>
|
||||
ep.season_number === currentSeason && ep.episode_number === (currentEpisode + 1)
|
||||
);
|
||||
if (currentEpisode !== null) {
|
||||
const nextEpisodeId = isImdb
|
||||
? `${id}:${currentSeason || episodes[0]?.season_number || 1}:${currentEpisode + 1}`
|
||||
: `${id}:${currentEpisode + 1}`;
|
||||
if (__DEV__) console.log(`[MetadataScreen] Created next episode ID: ${nextEpisodeId}`);
|
||||
|
||||
const nextEpisodeExists = episodes.some(ep => ep.episode_number === (currentEpisode + 1));
|
||||
if (nextEpisodeExists) {
|
||||
if (__DEV__) console.log(`[MetadataScreen] Verified next episode S${currentSeason}E${currentEpisode + 1} exists in episodes list`);
|
||||
if (__DEV__) console.log(`[MetadataScreen] Verified next episode exists`);
|
||||
} else {
|
||||
if (__DEV__) console.log(`[MetadataScreen] Warning: Next episode S${currentSeason}E${currentEpisode + 1} not found in episodes list, but proceeding anyway`);
|
||||
if (__DEV__) console.log(`[MetadataScreen] Warning: Next episode not found`);
|
||||
}
|
||||
|
||||
targetEpisodeId = nextEpisodeId;
|
||||
|
|
@ -656,10 +650,14 @@ const MetadataScreen: React.FC = () => {
|
|||
}
|
||||
|
||||
if (targetEpisodeId) {
|
||||
// Ensure the episodeId has showId prefix (id:season:episode)
|
||||
// Ensure the episodeId has showId prefix (id:season:episode or id:episode)
|
||||
const epParts = targetEpisodeId.split(':');
|
||||
let normalizedEpisodeId = targetEpisodeId;
|
||||
if (epParts.length === 2) {
|
||||
|
||||
if (epParts.length === 2 && !isImdb) {
|
||||
normalizedEpisodeId = `${id}:${epParts[1]}`;
|
||||
}
|
||||
else if (epParts.length === 2 && isImdb) {
|
||||
normalizedEpisodeId = `${id}:${epParts[0]}:${epParts[1]}`;
|
||||
}
|
||||
if (__DEV__) console.log(`[MetadataScreen] Navigating to streams with episodeId: ${normalizedEpisodeId}`);
|
||||
|
|
@ -672,7 +670,9 @@ const MetadataScreen: React.FC = () => {
|
|||
let fallbackEpisodeId = episodeId;
|
||||
if (episodeId && episodeId.split(':').length === 2) {
|
||||
const p = episodeId.split(':');
|
||||
fallbackEpisodeId = `${id}:${p[0]}:${p[1]}`;
|
||||
if (!p[0].startsWith('tt')) {
|
||||
fallbackEpisodeId = isImdb ? `${id}:${p[0]}:${p[1]}` : `${id}:${p[1]}`;
|
||||
}
|
||||
}
|
||||
if (__DEV__) console.log(`[MetadataScreen] Navigating with fallback episodeId: ${fallbackEpisodeId}`);
|
||||
navigation.navigate('Streams', { id, type, episodeId: fallbackEpisodeId });
|
||||
|
|
@ -682,7 +682,16 @@ const MetadataScreen: React.FC = () => {
|
|||
if (!isScreenFocused) return;
|
||||
|
||||
if (__DEV__) console.log('[MetadataScreen] Selected Episode:', episode.episode_number, episode.season_number);
|
||||
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
|
||||
|
||||
let episodeId: string;
|
||||
if (episode.stremioId) {
|
||||
episodeId = episode.stremioId;
|
||||
} else {
|
||||
const isImdb = id.startsWith('tt');
|
||||
episodeId = isImdb
|
||||
? `${id}:${episode.season_number}:${episode.episode_number}`
|
||||
: `${id}:${episode.episode_number}`;
|
||||
}
|
||||
|
||||
// Optimize navigation with requestAnimationFrame
|
||||
requestAnimationFrame(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
|
|
@ -11,7 +10,7 @@ import {
|
|||
View,
|
||||
} from 'react-native';
|
||||
import { NavigationProp, useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, ScrollView, StatusBar } from 'react-native';
|
||||
import { View, StyleSheet, ScrollView, StatusBar, Platform } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
|
@ -83,6 +83,22 @@ const DeveloperSettingsScreen: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const handleOpenPipTestStream = () => {
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
|
||||
title: 'PiP Test Stream',
|
||||
quality: '720',
|
||||
streamProvider: 'Dev Test',
|
||||
streamName: 'Mux HLS',
|
||||
id: 'dev-pip-test',
|
||||
type: 'movie',
|
||||
headers: {
|
||||
'User-Agent': 'Nuvio-PiP-Test',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Only show if developer mode is enabled (via __DEV__ or manually unlocked)
|
||||
if (!developerModeEnabled) {
|
||||
return null;
|
||||
|
|
@ -124,6 +140,13 @@ const DeveloperSettingsScreen: React.FC = () => {
|
|||
icon="refresh-cw"
|
||||
onPress={handleResetCampaigns}
|
||||
renderControl={() => <ChevronRight />}
|
||||
/>
|
||||
<SettingItem
|
||||
title={'PiP Test Stream (Temporary)'}
|
||||
description={'Open a public HLS stream for player/PiP smoke tests'}
|
||||
icon="tv"
|
||||
onPress={handleOpenPipTestStream}
|
||||
renderControl={() => <ChevronRight />}
|
||||
isLast
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
|
|
|||
|
|
@ -325,8 +325,17 @@ class StremioService {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Check if the ID matches any supported prefix
|
||||
return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase()));
|
||||
// Check if the ID matches any supported prefix.
|
||||
// For prefixes without a trailing separator (e.g. "mal", "kitsu"), the ID must be
|
||||
// longer than the prefix itself so that bare prefix strings like "mal" are rejected.
|
||||
const result = supportedPrefixes.some(prefix => {
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
if (!lowerId.startsWith(lowerPrefix)) return false;
|
||||
if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true;
|
||||
return lowerId.length > lowerPrefix.length;
|
||||
});
|
||||
if (__DEV__) console.log(`🔍 [isValidContentId] Prefix match result: ${result} for ID '${id}'`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get all content types supported by installed addons
|
||||
|
|
|
|||
|
|
@ -529,11 +529,16 @@ export class TMDBService {
|
|||
*/
|
||||
async extractTMDBIdFromStremioId(stremioId: string): Promise<number | null> {
|
||||
try {
|
||||
// Extract the base IMDB ID (remove season/episode info if present)
|
||||
const imdbId = stremioId.split(':')[0];
|
||||
// Extract the base ID (remove season/episode info if present)
|
||||
const baseId = stremioId.split(':')[0];
|
||||
|
||||
// Only try to convert if it's an IMDb ID (starts with 'tt')
|
||||
if (!baseId.startsWith('tt')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the existing findTMDBIdByIMDB function to get the TMDB ID
|
||||
const tmdbId = await this.findTMDBIdByIMDB(imdbId);
|
||||
const tmdbId = await this.findTMDBIdByIMDB(baseId);
|
||||
return tmdbId;
|
||||
} catch (error) {
|
||||
return null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue