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