mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 09:35:42 +00:00
Merge 649fc78a1d into 7d60a0c43f
This commit is contained in:
commit
e32fe35028
7 changed files with 180 additions and 9 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue