diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index cde7c0b0..fea5dd4c 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -23,7 +23,7 @@
-
+
diff --git a/patches/react-native-video+6.19.0.patch b/patches/react-native-video+6.19.0.patch
index 7b94a7ac..1b5e0e2f 100644
--- a/patches/react-native-video+6.19.0.patch
+++ b/patches/react-native-video+6.19.0.patch
@@ -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
diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx
index 1591f2f4..ed89b1f9 100644
--- a/src/components/player/AndroidVideoPlayer.tsx
+++ b/src/components/player/AndroidVideoPlayer.tsx
@@ -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(null);
+ const pipAutoEntryStateRef = useRef('');
+
// 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([]);
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}
/>
diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx
index ed89ba36..d728baa4 100644
--- a/src/components/player/android/components/VideoSurface.tsx
+++ b/src/components/player/android/components/VideoSurface.tsx
@@ -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 = ({
useExoPlayer = true,
onCodecError,
onEngineChange,
+ enterPictureInPictureOnLeave = false,
+ onPictureInPictureStatusChanged,
// Subtitle Styling
subtitleSize,
subtitleColor,
@@ -318,6 +322,14 @@ export const VideoSurface: React.FC = ({
console.log('[VideoSurface] Headers:', exoRequestHeaders);
}, [streamUrl, useExoPlayer, exoRequestHeaders]);
+ const lastPipAutoEnterStateRef = useRef(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 = ({
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 = ({
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={{
diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx
index bf0e5c95..85e5a6b4 100644
--- a/src/components/player/controls/PlayerControls.tsx
+++ b/src/components/player/controls/PlayerControls.tsx
@@ -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 = ({
onAirPlayPress,
onSwitchToMPV,
useExoPlayer,
+ canEnterPictureInPicture,
+ onEnterPictureInPicture,
isBuffering = false,
imdbId,
}) => {
@@ -399,6 +403,18 @@ export const PlayerControls: React.FC = ({
/>
)}
+ {Platform.OS === 'android' && canEnterPictureInPicture && onEnterPictureInPicture && (
+
+
+
+ )}
diff --git a/src/components/player/hooks/useWatchProgress.ts b/src/components/player/hooks/useWatchProgress.ts
index 8440e38e..bafafa64 100644
--- a/src/components/player/hooks/useWatchProgress.ts
+++ b/src/components/player/hooks/useWatchProgress.ts
@@ -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(null);
const [savedDuration, setSavedDuration] = useState(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);
}
diff --git a/src/screens/settings/DeveloperSettingsScreen.tsx b/src/screens/settings/DeveloperSettingsScreen.tsx
index 8b002e27..fcee9a8a 100644
--- a/src/screens/settings/DeveloperSettingsScreen.tsx
+++ b/src/screens/settings/DeveloperSettingsScreen.tsx
@@ -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={() => }
+ />
+ }
isLast
/>