From 2e88f36a5a37346f0707c4e8f9e0844e83dda213 Mon Sep 17 00:00:00 2001 From: Krzysztof Moch Date: Sun, 16 Nov 2025 18:27:59 +0100 Subject: [PATCH] fix: player initialization bugs (#4775) --- example/src/App.tsx | 4 ++- .../com/twg/video/core/AudioFocusManager.kt | 8 ++--- .../core/fragments/FullscreenVideoFragment.kt | 19 +++++++++--- .../PictureInPictureHelperFragment.kt | 1 + .../video/core/utils/PictureInPictureUtils.kt | 30 +++++++++++++++++-- .../hybrids/videoplayer/HybridVideoPlayer.kt | 8 +++-- .../HybridVideoViewViewManager.kt | 4 ++- .../main/java/com/twg/video/view/VideoView.kt | 14 +++++++++ .../src/core/hooks/useVideoPlayer.ts | 28 ++++++++++++++++- 9 files changed, 99 insertions(+), 17 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index 76c9939c..d090a9c6 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -121,7 +121,9 @@ const VideoDemo = () => { const player = useVideoPlayer( getVideoSource(defaultSettings.videoType), - (_player) => {} + (_player) => { + player.seekTo(1); + } ); useEvent(player, 'onEnd', handlePlayerEnd); diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/AudioFocusManager.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/AudioFocusManager.kt index 6126080f..cb51eac0 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/AudioFocusManager.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/AudioFocusManager.kt @@ -88,7 +88,7 @@ class AudioFocusManager() { private fun determineRequiredMixMode(): MixAudioMode? { val activePlayers = players.filter { player -> - player.player?.isPlaying == true && player.player?.volume != 0f + player.player.isPlaying && player.player.volume != 0f } if (activePlayers.isEmpty()) { @@ -195,7 +195,7 @@ class AudioFocusManager() { private fun pauseActivePlayers() { Threading.runOnMainThread { players.forEach { player -> - player.player?.let { mediaPlayer -> + player.player.let { mediaPlayer -> if (mediaPlayer.volume != 0f && mediaPlayer.isPlaying) { mediaPlayer.pause() } @@ -207,7 +207,7 @@ class AudioFocusManager() { private fun duckActivePlayers() { Threading.runOnMainThread { players.forEach { player -> - player.player?.let { mediaPlayer -> + player.player.let { mediaPlayer -> // We need to duck the volume to 50%. After the audio focus is regained, // we will restore the volume to the user's volume. mediaPlayer.volume = mediaPlayer.volume * 0.5f @@ -220,7 +220,7 @@ class AudioFocusManager() { Threading.runOnMainThread { // Resume players that were paused due to audio focus loss players.forEach { player -> - player.player?.let { mediaPlayer -> + player.player.let { mediaPlayer -> // Restore full volume if it was ducked if (mediaPlayer.volume != 0f && mediaPlayer.volume.toDouble() != player.userVolume) { mediaPlayer.volume = player.userVolume.toFloat() diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/fragments/FullscreenVideoFragment.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/fragments/FullscreenVideoFragment.kt index 34935885..32501561 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/fragments/FullscreenVideoFragment.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/fragments/FullscreenVideoFragment.kt @@ -83,18 +83,29 @@ class FullscreenVideoFragment(private val videoView: VideoView) : Fragment() { } } + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + + if (isInPictureInPictureMode) { + videoView.playerView.useController = false + videoView.playerView.controllerAutoShow = false + } else { + videoView.playerView.useController = videoView.useController + videoView.playerView.controllerAutoShow = true + } + } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - // Handle PiP mode changes - val isInPictureInPictureMode = - requireActivity().isInPictureInPictureMode + val isInPictureInPictureMode = requireActivity().isInPictureInPictureMode if (isInPictureInPictureMode) { - // Disable controls in PiP mode - media session creates its own controls for PiP videoView.playerView.useController = false + videoView.playerView.controllerAutoShow = false } else { videoView.playerView.useController = videoView.useController + videoView.playerView.controllerAutoShow = true } } diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/fragments/PictureInPictureHelperFragment.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/fragments/PictureInPictureHelperFragment.kt index a722491e..0d301d70 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/fragments/PictureInPictureHelperFragment.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/fragments/PictureInPictureHelperFragment.kt @@ -54,6 +54,7 @@ class PictureInPictureHelperFragment(private val videoView: VideoView) : Fragmen if (currentPipVideo == videoView) { // Disable controls immediately when entering PiP - media session creates its own controls for PiP videoView.playerView.useController = false + videoView.playerView.controllerAutoShow = false // If we're currently in fullscreen, exit it first to prevent parent conflicts if (videoView.isInFullscreen) { diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/utils/PictureInPictureUtils.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/utils/PictureInPictureUtils.kt index b9596afa..6b18b22e 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/utils/PictureInPictureUtils.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/utils/PictureInPictureUtils.kt @@ -32,7 +32,7 @@ object PictureInPictureUtils { } return builder - .setAspectRatio(calculateAspectRatio(videoView.playerView)) + .setAspectRatio(calculateAspectRatio(videoView)) .setSourceRectHint(calculateSourceRectHint(videoView.playerView)) .build() } @@ -50,14 +50,38 @@ object PictureInPictureUtils { return defaultParams.build() } - fun calculateAspectRatio(view: View): Rational { + fun calculateAspectRatio(videoView: VideoView): Rational { // AspectRatio for PIP must be between 2.39:1 and 1:2.39 // see: https://developer.android.com/reference/android/app/PictureInPictureParams.Builder#setAspectRatio(android.util.Rational) val maximumAspectRatio = Rational(239, 100) val minimumAspectRatio = Rational(100, 239) - val currentAspectRatio = Rational(view.width, view.height) + val player = videoView.hybridPlayer?.player + val videoFormat = player?.videoFormat + + var width: Int + var height: Int + + if (videoFormat != null && videoFormat.width > 0 && videoFormat.height > 0) { + width = videoFormat.width + height = videoFormat.height + + val rotationDegrees = videoFormat.rotationDegrees + if (rotationDegrees == 90 || rotationDegrees == 270) { + val temp = width + width = height + height = temp + } + + Log.d(TAG, "Using video format dimensions for PiP: ${width}x${height} (rotation: ${rotationDegrees}°)") + } else { + width = videoView.playerView.width + height = videoView.playerView.height + Log.d(TAG, "Using view dimensions for PiP: ${width}x${height}") + } + + val currentAspectRatio = Rational(width, height) return when { currentAspectRatio > maximumAspectRatio -> maximumAspectRatio diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayer.kt b/packages/react-native-video/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayer.kt index 3c16fca5..962003eb 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayer.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayer.kt @@ -60,7 +60,11 @@ class HybridVideoPlayer() : HybridVideoPlayerSpec() { throw LibraryError.ApplicationContextNotFound } - lateinit var player: ExoPlayer + var player: ExoPlayer = runOnMainThreadSync { + // Build Temporary player that will be replaced when source is loaded + return@runOnMainThreadSync ExoPlayer.Builder(context).build() + } + var loadedWithSource = false private var currentPlayerView: WeakReference? = null @@ -277,8 +281,6 @@ class HybridVideoPlayer() : HybridVideoPlayerSpec() { if (source.config.initializeOnCreation == true) { initializePlayer() player.prepare() - } else { - player = ExoPlayer.Builder(context).build() } } diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManager.kt b/packages/react-native-video/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManager.kt index 9042463e..1123641a 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManager.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManager.kt @@ -51,7 +51,9 @@ class HybridVideoViewViewManager(nitroId: Int): HybridVideoViewViewManagerSpec() override var autoEnterPictureInPicture: Boolean get() = videoView.get()?.autoEnterPictureInPicture == true set(value) { - videoView.get()?.autoEnterPictureInPicture = value + Threading.runOnMainThread { + videoView.get()?.autoEnterPictureInPicture = value + } } override var pictureInPicture: Boolean diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/view/VideoView.kt b/packages/react-native-video/android/src/main/java/com/twg/video/view/VideoView.kt index 35205bb4..8f35b89d 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/view/VideoView.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/view/VideoView.kt @@ -143,6 +143,17 @@ class VideoView @JvmOverloads constructor( var isInPictureInPicture: Boolean = false set(value) { field = value + + if (value) { + playerView.useController = false + playerView.controllerAutoShow = false + playerView.controllerHideOnTouch = true + } else { + playerView.useController = useController + playerView.controllerAutoShow = true + playerView.controllerHideOnTouch = true + } + events.onPictureInPictureChange?.let { it(value) } } private var rootContentViews: List = listOf() @@ -436,6 +447,7 @@ class VideoView @JvmOverloads constructor( // Disable controls before entering PiP - media session creates its own controls for PiP runOnMainThread { playerView.useController = false + playerView.controllerAutoShow = false } val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -475,6 +487,7 @@ class VideoView @JvmOverloads constructor( // Restore controls when exiting PiP - they were disabled because media session handles PiP controls playerView.useController = useController + playerView.controllerAutoShow = true if (movedToRootForPiP) { restoreRootContentViews() @@ -503,6 +516,7 @@ class VideoView @JvmOverloads constructor( // Restore controls when exiting PiP - they were disabled because media session handles PiP controls playerView.useController = useController + playerView.controllerAutoShow = true if (movedToRootForPiP) { restoreRootContentViews() diff --git a/packages/react-native-video/src/core/hooks/useVideoPlayer.ts b/packages/react-native-video/src/core/hooks/useVideoPlayer.ts index 747cfda1..a709a478 100644 --- a/packages/react-native-video/src/core/hooks/useVideoPlayer.ts +++ b/packages/react-native-video/src/core/hooks/useVideoPlayer.ts @@ -1,3 +1,4 @@ +import { useRef } from 'react'; import type { VideoPlayerSource } from '../../spec/nitro/VideoPlayerSource.nitro'; import type { NoAutocomplete } from '../types/Utils'; import type { VideoConfig, VideoSource } from '../types/VideoConfig'; @@ -19,6 +20,9 @@ const sourceEqual = ( /** * Creates a `VideoPlayer` instance and manages its lifecycle. * + * if `initializeOnCreation` is true (default), the `setup` function will be called when the player is started loading source. + * if `initializeOnCreation` is false, the `setup` function will be called when the player is created. changes made to player made before initializing will be overwritten when initializing. + * * @param source - The source of the video to play * @param setup - A function to setup the player * @returns The `VideoPlayer` instance @@ -27,15 +31,37 @@ export const useVideoPlayer = ( source: VideoConfig | VideoSource | NoAutocomplete, setup?: (player: VideoPlayer) => void ) => { + const setupCalled = useRef(false); + return useManagedInstance( { factory: () => { const player = new VideoPlayer(source); - setup?.(player); + + if (player.source.config.initializeOnCreation) { + // if source is small video, it can happen that onLoadStart is called before we set event from JS + // Thats why we adding event listener and calling setup once if player is loading or ready to play + // That way we ensure that setup is always called + + const callSetupOnce = () => { + if (!setupCalled.current) { + setupCalled.current = true; + console.log('calling setup'); + setup?.(player); + } + }; + + player.addEventListener('onLoadStart', callSetupOnce); + player.addEventListener('onStatusChange', callSetupOnce); + } else { + setup?.(player); + } + return player; }, cleanup: (player) => { player.__destroy(); + setupCalled.current = false; }, dependenciesEqualFn: sourceEqual, },