Compare commits

...

11 commits

Author SHA1 Message Date
LivinDuck
e32fe35028
Merge 649fc78a1d into 7d60a0c43f 2026-03-10 13:30:04 +09:00
Nayif
7d60a0c43f
Merge pull request #577 from skoruppa/main
fix for Kitsu and Mal content being still incorrectly requested
2026-03-09 05:19:50 +05:30
Nayif
e6c7bfc895
Merge pull request #565 from hoangtamthai/main
fix: Sync Setting Screen use correct SafeAreaView
2026-03-09 05:19:33 +05:30
Nayif
911730770b
Merge pull request #588 from saimuelbr/improve-language-detector
fix(i18n): use languageTag to detect region and select the correct language
2026-03-09 05:18:46 +05:30
Nayif
66b38c483b
Merge pull request #589 from saimuelbr/feat-scroll-filters
feat(ui): add horizontal scroll to download filters
2026-03-09 05:18:20 +05:30
saimuelbr
cf62ae74e6 fix(i18n): use languageTag to detect region and select the correct language 2026-03-03 15:37:10 -03:00
saimuelbr
51706e1478 feat(ui): add horizontal scroll to download filters 2026-03-02 18:02:42 -03:00
skoruppa
5bbc2abe8b fix for Kitsu and Mal content being still incorrectly requested 2026-03-02 09:17:58 +01:00
Thái Hoàng Tâm
91d81d53c1
fix: Sync Setting Screen use SafeAreaView from 'react-native-safe-area-context' instead of the deprecated one from 'react-native'
The bug is described at Discord https://discord.com/channels/1379902184207941732/1477029029449306358
2026-02-28 19:40:36 +01:00
LivinDuck
649fc78a1d fix: update MainActivity configuration to include smallestScreenSize for better layout handling 2026-02-28 22:45:34 +08:00
LivinDuck
03bea009b0 feature: add Picture-in-Picture support for Android video player. 2026-02-28 17:52:23 +08:00
14 changed files with 299 additions and 80 deletions

View file

@ -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"/>

View file

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

View file

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

View file

@ -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={{

View file

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

View file

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

View file

@ -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...');

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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

View file

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