This commit is contained in:
LivinDuck 2026-03-10 13:30:04 +09:00 committed by GitHub
commit e32fe35028
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 180 additions and 9 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

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