From fa91378bb1a1b7c655672729e6d4b15e0820d375 Mon Sep 17 00:00:00 2001 From: mmarchuk Date: Tue, 6 Jan 2026 16:02:04 +0100 Subject: [PATCH 1/2] Update dependencies, ExoPlayer API, and remove Flipper support Switched from `blacklist` to `exclusionList` in Metro config for compatibility. Upgraded Gradle, Android SDK versions, and ExoPlayer API usage. Removed Flipper support and related configurations for a leaner codebase. --- android/build.gradle | 5 +- .../PlayerVideo.kt | 41 ++++++-------- example/android/app/build.gradle | 9 --- .../ReactNativeFlipper.java | 55 +------------------ .../android/app/src/main/AndroidManifest.xml | 5 +- example/android/build.gradle | 14 ++--- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/metro.config.js | 11 +++- 8 files changed, 41 insertions(+), 101 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 94223ce..f06cd15 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,7 +4,7 @@ buildscript { repositories { google() - jcenter() + mavenCentral() } dependencies { @@ -52,7 +52,6 @@ android { repositories { mavenCentral() - jcenter() google() def found = false @@ -128,5 +127,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'com.google.android.exoplayer:exoplayer:2.11.2' + implementation 'com.google.android.exoplayer:exoplayer:2.18.7' } diff --git a/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt b/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt index c8f8ff1..dc39f8a 100644 --- a/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt +++ b/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt @@ -5,18 +5,15 @@ import android.net.Uri import android.os.Handler import android.util.Log import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.ExoPlaybackException +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.SimpleExoPlayer -import com.google.android.exoplayer2.source.ExtractorMediaSource import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.upstream.DataSource -import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory -import com.google.android.exoplayer2.util.Util -import com.google.android.exoplayer2.video.VideoListener +import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.video.VideoSize // @@ -34,7 +31,7 @@ class PlayerVideo(val context: Context) { // - val player = SimpleExoPlayer.Builder(context).build() + val player = ExoPlayer.Builder(context).build() var autoplay: Boolean = true @@ -98,22 +95,22 @@ class PlayerVideo(val context: Context) { Log.d("PlayerVideo", "load = ${url}") // Produces DataSource instances through which media data is loaded. - val dataSourceFactory: DataSource.Factory = DefaultDataSourceFactory( - context, - Util.getUserAgent(context, context.packageName) - ) + val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context) + + val mediaItem = MediaItem.fromUri(Uri.parse(url)) // This is the MediaSource representing the media to be played. val hlsMediaSource = HlsMediaSource.Factory(dataSourceFactory) - .createMediaSource(Uri.parse(url)) + .createMediaSource(mediaItem) val httpMediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(Uri.parse(url)) + .createMediaSource(mediaItem) val mediaSource = if(isHls) hlsMediaSource else httpMediaSource // Prepare the player with the source. - player.prepare(mediaSource) + player.setMediaSource(mediaSource) + player.prepare() player.playWhenReady = autoplay player.repeatMode = if(loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF @@ -122,9 +119,9 @@ class PlayerVideo(val context: Context) { setStatus(PlayerVideoStatus.new) // listeners - player.addListener(object: Player.EventListener { - override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { - Log.d("PlayerVideo", "onPlayerStateChanged = ${playbackState}") + player.addListener(object: Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + Log.d("PlayerVideo", "onPlaybackStateChanged = ${playbackState}") when(playbackState) { Player.STATE_IDLE -> setStatus(PlayerVideoStatus.none) @@ -136,12 +133,10 @@ class PlayerVideo(val context: Context) { } } } - }) - player.addVideoListener(object: VideoListener { - override fun onVideoSizeChanged(width: Int, height: Int, unappliedRotationDegrees: Int, pixelWidthHeightRatio: Float) { - Log.d("PlayerView", "onVideoSizeChanged width=${width}, height=${height}") - videoSizeChanged?.invoke(width, height) + override fun onVideoSizeChanged(videoSize: VideoSize) { + Log.d("PlayerView", "onVideoSizeChanged width=${videoSize.width}, height=${videoSize.height}") + videoSizeChanged?.invoke(videoSize.width, videoSize.height) } }) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 81d69fb..565b128 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -191,15 +191,6 @@ dependencies { implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" - debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") { - exclude group:'com.facebook.fbjni' - } - debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { - exclude group:'com.facebook.flipper' - } - debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") { - exclude group:'com.facebook.flipper' - } if (enableHermes) { def hermesPath = "../../node_modules/hermes-engine/android/"; diff --git a/example/android/app/src/debug/java/com/example/reactnativestandalonevideoplayer/ReactNativeFlipper.java b/example/android/app/src/debug/java/com/example/reactnativestandalonevideoplayer/ReactNativeFlipper.java index da97321..ce8ab0c 100644 --- a/example/android/app/src/debug/java/com/example/reactnativestandalonevideoplayer/ReactNativeFlipper.java +++ b/example/android/app/src/debug/java/com/example/reactnativestandalonevideoplayer/ReactNativeFlipper.java @@ -7,63 +7,10 @@ package com.example.reactnativestandalonevideoplayer; import android.content.Context; -import com.facebook.flipper.android.AndroidFlipperClient; -import com.facebook.flipper.android.utils.FlipperUtils; -import com.facebook.flipper.core.FlipperClient; -import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin; -import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin; -import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin; -import com.facebook.flipper.plugins.inspector.DescriptorMapping; -import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin; -import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor; -import com.facebook.flipper.plugins.network.NetworkFlipperPlugin; -import com.facebook.flipper.plugins.react.ReactFlipperPlugin; -import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin; import com.facebook.react.ReactInstanceManager; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.modules.network.NetworkingModule; -import okhttp3.OkHttpClient; public class ReactNativeFlipper { public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) { - if (FlipperUtils.shouldEnableFlipper(context)) { - final FlipperClient client = AndroidFlipperClient.getInstance(context); - client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())); - client.addPlugin(new ReactFlipperPlugin()); - client.addPlugin(new DatabasesFlipperPlugin(context)); - client.addPlugin(new SharedPreferencesFlipperPlugin(context)); - client.addPlugin(CrashReporterPlugin.getInstance()); - NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin(); - NetworkingModule.setCustomClientBuilder( - new NetworkingModule.CustomClientBuilder() { - @Override - public void apply(OkHttpClient.Builder builder) { - builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin)); - } - }); - client.addPlugin(networkFlipperPlugin); - client.start(); - // Fresco Plugin needs to ensure that ImagePipelineFactory is initialized - // Hence we run if after all native modules have been initialized - ReactContext reactContext = reactInstanceManager.getCurrentReactContext(); - if (reactContext == null) { - reactInstanceManager.addReactInstanceEventListener( - new ReactInstanceManager.ReactInstanceEventListener() { - @Override - public void onReactContextInitialized(ReactContext reactContext) { - reactInstanceManager.removeReactInstanceEventListener(this); - reactContext.runOnNativeModulesQueueThread( - new Runnable() { - @Override - public void run() { - client.addPlugin(new FrescoFlipperPlugin()); - } - }); - } - }); - } else { - client.addPlugin(new FrescoFlipperPlugin()); - } - } + // Flipper disabled } } diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index ca9e2ed..7d8a5dd 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -15,13 +15,14 @@ android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + android:exported="true"> - + diff --git a/example/android/build.gradle b/example/android/build.gradle index 5d5d188..d8b4894 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -2,17 +2,17 @@ buildscript { ext { - buildToolsVersion = "28.0.3" - minSdkVersion = 16 - compileSdkVersion = 28 - targetSdkVersion = 28 + buildToolsVersion = "33.0.0" + minSdkVersion = 21 + compileSdkVersion = 33 + targetSdkVersion = 33 } repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:3.5.2") + classpath("com.android.tools.build:gradle:4.1.3") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -32,7 +32,7 @@ allprojects { } google() - jcenter() + mavenCentral() maven { url 'https://www.jitpack.io' } } } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 1ba7206..1f3fdbc 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.0.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/example/metro.config.js b/example/metro.config.js index d1f468a..a2b71b7 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -1,8 +1,15 @@ const path = require('path'); -const blacklist = require('metro-config/src/defaults/blacklist'); const escape = require('escape-string-regexp'); const pak = require('../package.json'); +// Try exclusionList first (newer metro), fallback to blacklist (older metro) +let exclusionList; +try { + exclusionList = require('metro-config/src/defaults/exclusionList'); +} catch (e) { + exclusionList = require('metro-config/src/defaults/blacklist'); +} + const root = path.resolve(__dirname, '..'); const modules = Object.keys({ @@ -16,7 +23,7 @@ module.exports = { // We need to make sure that only one version is loaded for peerDependencies // So we blacklist them at the root, and alias them to the versions in example's node_modules resolver: { - blacklistRE: blacklist( + blacklistRE: exclusionList( modules.map( (m) => new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`) From bc3ddc9d02dfc58ed4f9a46786990ac38d10c92e Mon Sep 17 00:00:00 2001 From: mmarchuk Date: Wed, 7 Jan 2026 21:55:07 +0100 Subject: [PATCH 2/2] Add advanced video player sync and control features This update enhances video player functionality by adding volume and mute controls, synchronized playback for multiple players, and UI controls for progress and seeking. It also integrates '@react-native-community/slider' for more interactive player controls and ensures robust handling of video instances in React Native and native Android code. --- .../PlayerContainerView.kt | 137 +++++-- .../PlayerVideo.kt | 353 +++++++++++++----- .../StandaloneVideoPlayer.kt | 277 +++++++++----- example/package.json | 1 + example/src/App.tsx | 307 ++++++++++++--- example/yarn.lock | 5 + src/index.tsx | 32 +- 7 files changed, 823 insertions(+), 289 deletions(-) diff --git a/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerContainerView.kt b/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerContainerView.kt index 6e3523e..746afc5 100644 --- a/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerContainerView.kt +++ b/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerContainerView.kt @@ -1,20 +1,17 @@ package com.reactnativestandalonevideoplayer - import android.content.Context -import android.os.Looper import android.util.Log -import android.view.SurfaceView import com.facebook.react.uimanager.SimpleViewManager import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.annotations.ReactProp import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ui.AspectRatioFrameLayout -import com.google.android.exoplayer2.ui.PlayerView +import com.google.android.exoplayer2.ui.StyledPlayerView -class MyPlayerView(context: Context): PlayerView(context) { +class MyPlayerView(context: Context): StyledPlayerView(context) { var playerInstance: Int = -1 var isBound: Boolean = false @@ -25,65 +22,133 @@ class MyPlayerView(context: Context): PlayerView(context) { class PlayerContainerView: SimpleViewManager() { + companion object { + private const val TAG = "PlayerContainerView" + + // Thread-safe pending views management + private val pendingViewsLock = Any() + private val _pendingViews: MutableList = mutableListOf() + + fun addPendingView(view: MyPlayerView) = synchronized(pendingViewsLock) { + if (!_pendingViews.contains(view)) { + _pendingViews.add(view) + } + } + + fun removePendingView(view: MyPlayerView) = synchronized(pendingViewsLock) { + _pendingViews.remove(view) + } + + fun clearPendingViews() = synchronized(pendingViewsLock) { + _pendingViews.clear() + } + + fun bindPendingViews() = synchronized(pendingViewsLock) { + val iterator = _pendingViews.iterator() + while (iterator.hasNext()) { + val view = iterator.next() + val player = PlayerVideo.getInstance(view.playerInstance) + if (view.playerInstance >= 0 && player != null) { + val targetPlayer = if (view.isBound) player.player else null + view.player = targetPlayer + view.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL + (targetPlayer as? ExoPlayer)?.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT + iterator.remove() + } + } + } + + fun hasPendingViews(): Boolean = synchronized(pendingViewsLock) { + _pendingViews.isNotEmpty() + } + } init { - // this is created only once! - Log.d("PlayerView", "init PlayerContainerView") + Log.d(TAG, "init PlayerContainerView") } override fun getName() = "RNTPlayerVideoView" @ReactProp(name = "isBoundToPlayer") fun boundToPlayer(view: MyPlayerView, isBoundToPlayer: Boolean) { - Log.d("PlayerView", "boundToPlayer = ${isBoundToPlayer}") + Log.d(TAG, "boundToPlayer = $isBoundToPlayer") - view.isBound = isBoundToPlayer - - setup(view) + if (view.isBound != isBoundToPlayer) { + view.isBound = isBoundToPlayer + setup(view) + } } @ReactProp(name = "playerInstance") fun setPlayerInstance(view: MyPlayerView, instance: Int) { - Log.d("PlayerView", "playerInstance = ${instance}") + Log.d(TAG, "playerInstance = $instance") - view.playerInstance = instance - - setup(view) + if (view.playerInstance != instance) { + view.playerInstance = instance + setup(view) + } } private fun setup(view: MyPlayerView) { - Log.d("PlayerView", "bind isBound=${view.isBound}, playerInstance=${view.playerInstance}") - Log.d("PlayerView", "view = ${view}") + Log.d(TAG, "setup isBound=${view.isBound}, playerInstance=${view.playerInstance}") - if (view.playerInstance < 0 || view.playerInstance >= PlayerVideo.instances.size) { + if (view.playerInstance < 0) { return } - view.player = if (view.isBound) PlayerVideo.instances[view.playerInstance].player else null - view.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL - (view.player as? SimpleExoPlayer)?.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + // Wait for instance to be created if not yet available + val playerVideo = PlayerVideo.getInstance(view.playerInstance) + if (playerVideo == null) { + addPendingView(view) + return + } + + // Remove from pending views if it was there + removePendingView(view) + + val targetPlayer = if (view.isBound) playerVideo.player else null - // + // Always update player binding when isBound is true if (view.isBound) { - // we have to setup again after videoSizeChanged otherwise video ratio would be wrong - PlayerVideo.instances[view.playerInstance].videoSizeChanged = { width, height -> - setup(view) + view.player = targetPlayer + view.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL + (targetPlayer as? ExoPlayer)?.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT + + // Set up video size callback + playerVideo.videoSizeChanged = { _, _ -> + // Refresh resize mode when video size changes + view.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL } + } else { + // Unbind player when not bound + view.player = null } - } - // + // Try to bind any pending views now that we have more instances + if (hasPendingViews()) { + bindPendingViews() + } + } override fun createViewInstance(reactContext: ThemedReactContext): MyPlayerView { - val playerView = MyPlayerView(reactContext) - - playerView.useController = false - playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL - playerView.player = null + Log.d(TAG, "createViewInstance") + + return MyPlayerView(reactContext).apply { + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL + player = null + // Disable shutter view for faster display + setShutterBackgroundColor(android.graphics.Color.TRANSPARENT) + } + } - Log.d("PlayerView", "createViewInstance") + override fun onDropViewInstance(view: MyPlayerView) { + Log.d(TAG, "onDropViewInstance") - return playerView + // Remove from pending views to prevent memory leak + removePendingView(view) + // Unbind player + view.player = null + super.onDropViewInstance(view) } - } diff --git a/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt b/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt index dc39f8a..5e75575 100644 --- a/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt +++ b/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt @@ -3,35 +3,111 @@ package com.reactnativestandalonevideoplayer import android.content.Context import android.net.Uri import android.os.Handler +import android.os.Looper import android.util.Log import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.DefaultLoadControl +import com.google.android.exoplayer2.DefaultRenderersFactory import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.upstream.DataSource import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource import com.google.android.exoplayer2.video.VideoSize +import java.util.concurrent.atomic.AtomicBoolean -// +class PlayerVideo(val context: Context) { + companion object { + private const val TAG = "PlayerVideo" -class PlayerVideo(val context: Context) { + // Thread-safe instance management + private val instancesLock = Any() + private val _instances: MutableList = mutableListOf() + // Thread-safe read-only access to instances list + val instances: List + get() = synchronized(instancesLock) { _instances.toList() } - private var status: PlayerVideoStatus = PlayerVideoStatus.none + val instanceCount: Int + get() = synchronized(instancesLock) { _instances.size } - private var progressHandler: Handler? = null - private var progressRunnable: Runnable? = null + fun addInstance(instance: PlayerVideo) = synchronized(instancesLock) { + _instances.add(instance) + } + + fun getInstance(index: Int): PlayerVideo? = synchronized(instancesLock) { + _instances.getOrNull(index) + } - private val PROGRESS_UPDATE_TIME: Long = 1000 + fun clearInstances() = synchronized(instancesLock) { + _instances.clear() + } - // + fun releaseAllInstances() = synchronized(instancesLock) { + _instances.forEach { it.release() } + _instances.clear() + } + + // Thread-safe DataSource factory (double-checked locking) + @Volatile + private var sharedDataSourceFactory: DataSource.Factory? = null + private val factoryLock = Any() + + fun getDataSourceFactory(context: Context): DataSource.Factory { + return sharedDataSourceFactory ?: synchronized(factoryLock) { + sharedDataSourceFactory ?: DefaultDataSource.Factory( + context.applicationContext, + DefaultHttpDataSource.Factory() + .setConnectTimeoutMs(8000) + .setReadTimeoutMs(8000) + .setAllowCrossProtocolRedirects(true) + ).also { sharedDataSourceFactory = it } + } + } + } - val player = ExoPlayer.Builder(context).build() + private var status: PlayerVideoStatus = PlayerVideoStatus.none + + private var progressHandler: Handler? = null + private var progressRunnable: Runnable? = null + private var isProgressTimerRunning = false + + private val PROGRESS_UPDATE_TIME: Long = 500 // Faster updates for smoother UI + + // Optimized load control for faster startup and lower memory usage + private val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + 1500, // Min buffer before playback starts - faster start + 15000, // Max buffer - balance between memory and smoothness + 500, // Buffer for playback - minimal delay + 1500 // Buffer for rebuffer + ) + .setPrioritizeTimeOverSizeThresholds(true) + .build() + + // Optimized renderers for faster decoding + private val renderersFactory = DefaultRenderersFactory(context) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) + .setEnableDecoderFallback(true) + + val player: ExoPlayer = ExoPlayer.Builder(context) + .setLoadControl(loadControl) + .setRenderersFactory(renderersFactory) + .setHandleAudioBecomingNoisy(true) // Pause when headphones unplugged + .build() + + // Player listener reference for cleanup + private var playerListener: Player.Listener? = null + + // Atomic flag to prevent race conditions during release + private val _isReleased = AtomicBoolean(false) + val isReleased: Boolean get() = _isReleased.get() var autoplay: Boolean = true @@ -41,72 +117,95 @@ class PlayerVideo(val context: Context) { var videoSizeChanged: ((width: Int, height: Int) -> Unit)? = null - var currentStatus: PlayerVideoStatus + val currentStatus: PlayerVideoStatus get() = status - set(value) {} - var isPlaying: Boolean + val isPlaying: Boolean get() = status == PlayerVideoStatus.playing - set(value) {} - var isLoaded: Boolean + val isLoaded: Boolean get() = status == PlayerVideoStatus.playing || status == PlayerVideoStatus.paused || status == PlayerVideoStatus.loading - set(value) {} - var isLoading: Boolean + val isLoading: Boolean get() = status == PlayerVideoStatus.loading - set(value) {} + + private var _isMuted: Boolean = false + private var _volumeBeforeMute: Float = 1f var volume: Float get() = player.volume - set(value) { player.volume = value } + set(value) { + if (!isReleased) { + player.volume = value.coerceIn(0f, 1f) + // If setting volume > 0, update muted state + if (value > 0f) { + _isMuted = false + _volumeBeforeMute = value.coerceIn(0f, 1f) + } + } + } - var duration: Double + var muted: Boolean + get() = _isMuted + set(value) { + if (!isReleased) { + _isMuted = value + if (value) { + // Save current volume before muting + if (player.volume > 0f) { + _volumeBeforeMute = player.volume + } + player.volume = 0f + } else { + // Restore volume when unmuting + player.volume = _volumeBeforeMute + } + } + } + + val duration: Double get() { if (player.duration == C.TIME_UNSET) { - Log.d("PlayerVideo", "DURRRRR: TIME_UNSET") return 0.0 } - - val dur = player.duration.toDouble() - - Log.d("PlayerVideo", "DURRRRR: ${dur}") - return dur + return player.duration.toDouble() } - set(value){} - var position: Double + val position: Double get() = player.currentPosition.toDouble() - set(value){} - var progress: Double + val progress: Double get() { if (player.duration > 0) { return player.currentPosition.toDouble() / player.duration.toDouble() } - return 0.0 } - set(value) {} // fun loadVideo(url: String, isHls: Boolean, loop: Boolean) { - Log.d("PlayerVideo", "load = ${url}") - - // Produces DataSource instances through which media data is loaded. - val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context) + if (isReleased) { + Log.w(TAG, "Cannot load video - player is released") + return + } - val mediaItem = MediaItem.fromUri(Uri.parse(url)) + Log.d(TAG, "loadVideo url=$url, isHls=$isHls, loop=$loop") - // This is the MediaSource representing the media to be played. - val hlsMediaSource = HlsMediaSource.Factory(dataSourceFactory) - .createMediaSource(mediaItem) + // Use shared DataSource factory for better performance + val dataSourceFactory = getDataSourceFactory(context) - val httpMediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(mediaItem) + val mediaItem = MediaItem.fromUri(Uri.parse(url)) - val mediaSource = if(isHls) hlsMediaSource else httpMediaSource + // Create appropriate media source + val mediaSource = if(isHls) { + HlsMediaSource.Factory(dataSourceFactory) + .setAllowChunklessPreparation(true) // Faster HLS start + .createMediaSource(mediaItem) + } else { + ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(mediaItem) + } // Prepare the player with the source. player.setMediaSource(mediaSource) @@ -114,84 +213,142 @@ class PlayerVideo(val context: Context) { player.playWhenReady = autoplay player.repeatMode = if(loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF - player.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + player.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT setStatus(PlayerVideoStatus.new) - // listeners - player.addListener(object: Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - Log.d("PlayerVideo", "onPlaybackStateChanged = ${playbackState}") - - when(playbackState) { - Player.STATE_IDLE -> setStatus(PlayerVideoStatus.none) - Player.STATE_BUFFERING -> setStatus(PlayerVideoStatus.loading) - Player.STATE_READY -> setStatus(if(player.playWhenReady) PlayerVideoStatus.playing else PlayerVideoStatus.paused) - Player.STATE_ENDED -> { - setStatus(PlayerVideoStatus.finished) - stopProgressTimer() + // Add listener only once (store reference for cleanup) + if (playerListener == null) { + playerListener = object: Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (isReleased) return + Log.d(TAG, "onPlaybackStateChanged = $playbackState") + + when(playbackState) { + Player.STATE_IDLE -> {} // Don't change status on idle, we manage it manually + Player.STATE_BUFFERING -> setStatus(PlayerVideoStatus.loading) + Player.STATE_READY -> setStatus(if(player.playWhenReady) PlayerVideoStatus.playing else PlayerVideoStatus.paused) + Player.STATE_ENDED -> { + setStatus(PlayerVideoStatus.finished) + stopProgressTimer() + } } } - } - override fun onVideoSizeChanged(videoSize: VideoSize) { - Log.d("PlayerView", "onVideoSizeChanged width=${videoSize.width}, height=${videoSize.height}") - videoSizeChanged?.invoke(videoSize.width, videoSize.height) + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isReleased) return + if (player.playbackState == Player.STATE_READY) { + setStatus(if(isPlaying) PlayerVideoStatus.playing else PlayerVideoStatus.paused) + } + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + if (isReleased) return + Log.d(TAG, "onVideoSizeChanged width=${videoSize.width}, height=${videoSize.height}") + videoSizeChanged?.invoke(videoSize.width, videoSize.height) + } + + override fun onPlayerError(error: PlaybackException) { + if (isReleased) return + Log.e(TAG, "Playback error: ${error.errorCode}, ${error.message}") + setStatus(PlayerVideoStatus.error) + } } - }) + player.addListener(playerListener!!) + } startProgressTimer() } fun play() { - Log.d("PlayerVideo", "play") + if (isReleased) return + Log.d(TAG, "play") if (status === PlayerVideoStatus.finished) { seek(0.0) } player.playWhenReady = true - startProgressTimer() } fun pause() { - Log.d("PlayerVideo", "pause") - + if (isReleased) return + Log.d(TAG, "pause") player.playWhenReady = false - - stopProgressTimer() } fun stop() { - Log.d("PlayerVideo", "stop") + if (isReleased) return + Log.d(TAG, "stop") player.stop() - + player.clearMediaItems() setStatus(PlayerVideoStatus.stopped) + stopProgressTimer() + } + fun clear() { + if (isReleased) return + Log.d(TAG, "clear") + + player.stop() + player.clearMediaItems() + setStatus(PlayerVideoStatus.none) stopProgressTimer() } fun seek(progress: Double) { - Log.d("PlayerVideo", "seek: ${progress}") + if (isReleased) return + Log.d(TAG, "seek: $progress") - player.seekTo((duration * progress).toLong()) + val clampedProgress = progress.coerceIn(0.0, 1.0) + player.seekTo((duration * clampedProgress).toLong()) + // Immediately notify progress change after seek + progressChanged?.invoke(clampedProgress, duration) } fun seekForward(time: Double) { - Log.d("PlayerVideo", "Seek forward position=${position}, by=${time*1000}") - - player.seekTo((position + time*1000).toLong()) + if (isReleased || time < 0) return + Log.d(TAG, "seekForward position=$position, by=${time * 1000}") + + val maxPosition = if (player.duration > 0) player.duration else Long.MAX_VALUE + val newPosition = (position + time * 1000).toLong().coerceAtMost(maxPosition) + player.seekTo(newPosition) + // Immediately notify progress change after seek + progressChanged?.invoke(this.progress, duration) } fun seekRewind(time: Double) { - Log.d("PlayerVideo", "Seek rewind position=${position}, by=${time*1000}") + if (isReleased || time < 0) return + Log.d(TAG, "seekRewind position=$position, by=${time * 1000}") - player.seekTo((position - time * 1000).toLong()) + val newPosition = (position - time * 1000).toLong().coerceAtLeast(0) + player.seekTo(newPosition) + // Immediately notify progress change after seek + progressChanged?.invoke(this.progress, duration) } fun release() { + // Atomic check-and-set to prevent double release + if (!_isReleased.compareAndSet(false, true)) return + Log.d(TAG, "release") + + stopProgressTimer() + + // Remove listener to prevent memory leaks + playerListener?.let { player.removeListener(it) } + playerListener = null + + // Clear callbacks to prevent memory leaks + statusChanged = null + progressChanged = null + videoSizeChanged = null + + // Clear handler completely + progressHandler?.removeCallbacksAndMessages(null) + progressHandler = null + player.release() } @@ -199,11 +356,9 @@ class PlayerVideo(val context: Context) { // Private // - private fun setStatus(newStatus: PlayerVideoStatus) { status = newStatus - - Log.d("PlayerVideo", "NEW status = ${status}") + Log.d(TAG, "NEW status = $status") statusChanged?.invoke(status) @@ -213,31 +368,34 @@ class PlayerVideo(val context: Context) { } private fun startProgressTimer() { - progressHandler = Handler() + if (isProgressTimerRunning || isReleased) return // Don't start if already running or released - progressRunnable = Runnable { - progressChanged?.invoke(progress, duration) + isProgressTimerRunning = true - progressRunnable?.let { - progressHandler?.postDelayed(it, PROGRESS_UPDATE_TIME) - } + if (progressHandler == null) { + progressHandler = Handler(Looper.getMainLooper()) } - progressRunnable?.let { - progressHandler?.postDelayed(it, 0) + progressRunnable = object : Runnable { + override fun run() { + if (isProgressTimerRunning && !isReleased) { + // Always send progress update + progressChanged?.invoke(progress, duration) + // Always continue the timer loop + progressHandler?.postDelayed(this, PROGRESS_UPDATE_TIME) + } + } } + + progressHandler?.post(progressRunnable!!) } private fun stopProgressTimer() { + isProgressTimerRunning = false progressRunnable?.let { progressHandler?.removeCallbacks(it) } - } - - // - - companion object { - var instances: MutableList = mutableListOf() + progressRunnable = null } } @@ -248,12 +406,7 @@ enum class PlayerVideoStatus(val value: Int) { playing(2), paused(3), error(4), - stopped(5), // stopped by the user + stopped(5), none(6), - finished(7) // done playing -} - -fun printPlayerStatus(status: PlayerVideoStatus) : String { - return "" + finished(7) } - diff --git a/android/src/main/java/com/reactnativestandalonevideoplayer/StandaloneVideoPlayer.kt b/android/src/main/java/com/reactnativestandalonevideoplayer/StandaloneVideoPlayer.kt index e0a2d40..a1d3ac5 100644 --- a/android/src/main/java/com/reactnativestandalonevideoplayer/StandaloneVideoPlayer.kt +++ b/android/src/main/java/com/reactnativestandalonevideoplayer/StandaloneVideoPlayer.kt @@ -1,8 +1,6 @@ package com.reactnativestandalonevideoplayer - import android.os.Handler -import android.os.Looper import android.util.Log import com.facebook.react.bridge.* import com.facebook.react.modules.core.DeviceEventManagerModule @@ -18,34 +16,40 @@ class StandaloneVideoPlayer(val context: ReactApplicationContext): ReactContextB // We use main thread here (Handler(context.mainLooper).post) // - // + companion object { + private const val TAG = "StandaloneVideoPlayer" + } init { context.addLifecycleEventListener(this) - newInstance(); + // Create first instance synchronously to avoid race condition + val instance = PlayerVideo(context) + PlayerVideo.addInstance(instance) } // // LifecycleEventListener // override fun onHostResume() { - Log.d("PlayerVideo", "onHostResume") + Log.d(TAG, "onHostResume") + // App resumed from background } override fun onHostPause() { - Log.d("PlayerVideo", "onHostPause") - - for (instance in PlayerVideo.instances) { - instance.stop() - } + Log.d(TAG, "onHostPause") + // Don't stop players - allow background playback + // Players will continue playing and maintain position } override fun onHostDestroy() { + Log.d(TAG, "onHostDestroy") Handler(context.mainLooper).post { - for (instance in PlayerVideo.instances) { - instance.stop() - instance.release() + try { + PlayerVideo.releaseAllInstances() + PlayerContainerView.clearPendingViews() + } catch (e: Exception) { + Log.e(TAG, "Error in onHostDestroy: ${e.message}") } } } @@ -56,174 +60,251 @@ class StandaloneVideoPlayer(val context: ReactApplicationContext): ReactContextB @ReactMethod fun newInstance() { - // intialize player instance Handler(context.mainLooper).post { - val instance = PlayerVideo(context) - PlayerVideo.instances.add(instance) + try { + val instance = PlayerVideo(context) + PlayerVideo.addInstance(instance) + Log.d(TAG, "Created new instance, total: ${PlayerVideo.instanceCount}") + } catch (e: Exception) { + Log.e(TAG, "Error creating new instance: ${e.message}") + } } } @ReactMethod fun load(instance: Int, url: String, isHls: Boolean, loop: Boolean, isSilent: Boolean) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { + if (instance < 0) { + Log.w(TAG, "Invalid instance: $instance") return } Handler(context.mainLooper).post { - Log.d("PlayerVideo", "Load = ${url}") - - // mqt_native_modules - Log.d("PlayerVideo", "LOOPER load = ${Looper.myLooper()}, main = ${context.mainLooper}") - - PlayerVideo.instances[instance].statusChanged = { status -> - Log.d("PlayerVideo", "STATUS = ${status}") - - val map = Arguments.createMap() - map.putInt("status", status.value) - map.putInt("instance", instance) - - context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit("PlayerStatusChanged", map) - } - - PlayerVideo.instances[instance].progressChanged = { progress, duration -> - Log.d("PlayerVideo", "PROGRESS = ${progress}") - - val map = Arguments.createMap() - map.putDouble("progress", progress) - map.putDouble("duration", duration / 1000) - map.putInt("instance", instance) - - context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit("PlayerProgressChanged", map) - } - - PlayerVideo.instances[instance].loadVideo(url, isHls, loop) - - if (isSilent) { - PlayerVideo.instances[instance].volume = 0f + try { + // Create new instances if needed + while (PlayerVideo.instanceCount <= instance) { + val newInstance = PlayerVideo(context) + PlayerVideo.addInstance(newInstance) + Log.d(TAG, "Auto-created instance, total: ${PlayerVideo.instanceCount}") + } + + // Always try to bind pending views after ensuring instances exist + PlayerContainerView.bindPendingViews() + + val player = PlayerVideo.getInstance(instance) + if (player == null) { + Log.e(TAG, "Failed to get instance $instance") + return@post + } + + player.statusChanged = { status -> + try { + val map = Arguments.createMap() + map.putInt("status", status.value) + map.putInt("instance", instance) + + context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("PlayerStatusChanged", map) + } catch (e: Exception) { + Log.e(TAG, "Error emitting status: ${e.message}") + } + } + + player.progressChanged = { progress, duration -> + try { + val map = Arguments.createMap() + map.putDouble("progress", progress) + map.putDouble("duration", duration / 1000) + map.putInt("instance", instance) + + context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("PlayerProgressChanged", map) + } catch (e: Exception) { + Log.e(TAG, "Error emitting progress: ${e.message}") + } + } + + player.loadVideo(url, isHls, loop) + + if (isSilent) { + player.volume = 0f + } + } catch (e: Exception) { + Log.e(TAG, "Error in load: ${e.message}") } } - - } @ReactMethod fun stop(instance: Int) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { - return - } + if (instance < 0) return Handler(context.mainLooper).post { - Log.d("PlayerVideo", "STOOOOP!!") - - PlayerVideo.instances[instance].stop() - - PlayerVideo.instances[instance].statusChanged = null + try { + PlayerVideo.getInstance(instance)?.let { player -> + player.stop() + player.statusChanged = null + } + } catch (e: Exception) { + Log.e(TAG, "Error in stop: ${e.message}") + } } } @ReactMethod fun play(instance: Int) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { - return - } + if (instance < 0) return Handler(context.mainLooper).post { - Log.d("PlayerVideo", "PLAY") - - PlayerVideo.instances[instance].play() + try { + PlayerVideo.getInstance(instance)?.play() + } catch (e: Exception) { + Log.e(TAG, "Error in play: ${e.message}") + } } } @ReactMethod fun pause(instance: Int) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { - return - } + if (instance < 0) return Handler(context.mainLooper).post { - Log.d("PlayerVideo", "PAUSE") - - PlayerVideo.instances[instance].pause() + try { + PlayerVideo.getInstance(instance)?.pause() + } catch (e: Exception) { + Log.e(TAG, "Error in pause: ${e.message}") + } } } @ReactMethod fun seek(instance: Int, position: Double) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { - return - } + if (instance < 0) return Handler(context.mainLooper).post { - Log.d("PlayerVideo", "SEEK TO = ${position}") - - PlayerVideo.instances[instance].seek(position) + try { + PlayerVideo.getInstance(instance)?.seek(position) + } catch (e: Exception) { + Log.e(TAG, "Error in seek: ${e.message}") + } } } @ReactMethod fun seekForward(instance: Int, time: Double) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { - return - } + if (instance < 0) return Handler(context.mainLooper).post { - Log.d("PlayerVideo", "SEEK FORWARD by = ${time}") - - PlayerVideo.instances[instance].seekForward(time) + try { + PlayerVideo.getInstance(instance)?.seekForward(time) + } catch (e: Exception) { + Log.e(TAG, "Error in seekForward: ${e.message}") + } } } @ReactMethod fun seekRewind(instance: Int, time: Double) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { - return - } + if (instance < 0) return Handler(context.mainLooper).post { - Log.d("PlayerVideo", "SEEK FORWARD by = ${time}") - - PlayerVideo.instances[instance].seekRewind(time) + try { + PlayerVideo.getInstance(instance)?.seekRewind(time) + } catch (e: Exception) { + Log.e(TAG, "Error in seekRewind: ${e.message}") + } } } @ReactMethod fun setVolume(instance: Int, volume: Float) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { + if (instance < 0) return + + Handler(context.mainLooper).post { + try { + PlayerVideo.getInstance(instance)?.let { it.volume = volume } + } catch (e: Exception) { + Log.e(TAG, "Error in setVolume: ${e.message}") + } + } + } + + @ReactMethod + fun getMuted(instance: Int, promise: Promise) { + if (instance < 0) { + promise.resolve(false) return } Handler(context.mainLooper).post { - Log.d("PlayerVideo", "setVolume = ${volume}") + try { + val player = PlayerVideo.getInstance(instance) + val muted = player?.muted ?: false + promise.resolve(muted) + } catch (e: Exception) { + Log.e(TAG, "Error in getMuted: ${e.message}") + promise.resolve(false) + } + } + } - PlayerVideo.instances[instance].volume = volume + @ReactMethod + fun setMuted(instance: Int, muted: Boolean) { + if (instance < 0) return + + Handler(context.mainLooper).post { + try { + PlayerVideo.getInstance(instance)?.let { it.muted = muted } + } catch (e: Exception) { + Log.e(TAG, "Error in setMuted: ${e.message}") + } } } @ReactMethod fun getDuration(instance: Int, promise: Promise) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { + if (instance < 0) { promise.resolve(0) return } Handler(context.mainLooper).post { - val duration = PlayerVideo.instances[instance].duration / 1000 - promise.resolve(duration) + try { + val player = PlayerVideo.getInstance(instance) + val duration = player?.duration?.div(1000) ?: 0.0 + promise.resolve(duration) + } catch (e: Exception) { + Log.e(TAG, "Error in getDuration: ${e.message}") + promise.resolve(0) + } } } @ReactMethod fun getProgress(instance: Int, promise: Promise) { - if (instance < 0 || instance >= PlayerVideo.instances.size) { + if (instance < 0) { promise.resolve(0) return } Handler(context.mainLooper).post { - val duration = PlayerVideo.instances[instance].progress - promise.resolve(duration) + try { + val player = PlayerVideo.getInstance(instance) + val progress = player?.progress ?: 0.0 + promise.resolve(progress) + } catch (e: Exception) { + Log.e(TAG, "Error in getProgress: ${e.message}") + promise.resolve(0) + } } } + + @ReactMethod + fun addListener(eventName: String) { + // Required for RN NativeEventEmitter + } + + @ReactMethod + fun removeListeners(count: Int) { + // Required for RN NativeEventEmitter + } } diff --git a/example/package.json b/example/package.json index e28684b..926f572 100644 --- a/example/package.json +++ b/example/package.json @@ -9,6 +9,7 @@ "start": "react-native start" }, "dependencies": { + "@react-native-community/slider": "3.0.3", "react": "16.11.0", "react-native": "0.62.2" }, diff --git a/example/src/App.tsx b/example/src/App.tsx index f65da86..1c0f6ad 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,97 +1,253 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { StyleSheet, View, FlatList, TouchableOpacity, - Image, - Platform, + ViewToken, + Text, } from 'react-native'; +import Slider from '@react-native-community/slider'; import { useVideoPlayer, PlayerVideoView, + usePlayerVideoProgress, + usePlayerVideoStatus, + PlayerVideoManager, + PlayerStatus, } from 'react-native-standalone-video-player'; -console.disableYellowBox = true; +// + +// Video URLs +const VideoUrls = [ + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8', + 'https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', + 'https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', +]; // +// Sync configuration: players 1 and 2 will be synchronized +const SYNCED_PLAYERS = [1, 2]; +const SYNC_VIDEO_URL = VideoUrls[1]; // Both synced players use the same video + type ItemProps = { index: number; + videoUrl: string; + isVisible: boolean; + isSynced: boolean; + onSyncAction?: (action: 'play' | 'pause' | 'seek' | 'seekForward' | 'seekRewind', value?: number) => void; +}; - isActive: boolean; +// - onPress?: (index: number) => void; +const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; }; -// -const CoverUrl = 'https://www.voicesummit.ai/hubfs/video-placeholder.jpg'; -const VideoUrl = - 'https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8'; +const VideoItem = ({ index, videoUrl, isVisible, isSynced, onSyncAction }: ItemProps) => { + const player = useVideoPlayer(index); + const hasLoaded = useRef(false); + const [isMuted, setIsMuted] = useState(false); + const [isSeeking, setIsSeeking] = useState(false); + const { progress, duration } = usePlayerVideoProgress(index); + const { status } = usePlayerVideoStatus(index); -const isIOS = Platform.OS === 'ios'; + // Derive isPaused from actual player status + const isPaused = status === PlayerStatus.paused || status === PlayerStatus.stopped || status === PlayerStatus.finished; -// + // Check if URL is HLS + const isHls = videoUrl.includes('.m3u8'); + + useEffect(() => { + // Load video immediately on mount + if (!hasLoaded.current) { + console.log(`[Player ${index}] Loading video`); + player.load(videoUrl, true, `video-${index}`, isHls, true, false); + hasLoaded.current = true; + } + }, [player, videoUrl, index, isHls]); + + const togglePlayPause = () => { + if (isPaused) { + player.play(); + if (isSynced && onSyncAction) onSyncAction('play'); + } else { + player.pause(); + if (isSynced && onSyncAction) onSyncAction('pause'); + } + }; + + const toggleMute = () => { + const newMuted = !isMuted; + setIsMuted(newMuted); + PlayerVideoManager.setVolume(index, newMuted ? 0 : 1); + }; + + const handleSeek = (value: number) => { + setIsSeeking(false); + player.seek(value); + if (isSynced && onSyncAction) onSyncAction('seek', value); + }; + + const handleSeekRewind = () => { + player.seekRewind(10); + if (isSynced && onSyncAction) onSyncAction('seekRewind', 10); + }; + + const handleSeekForward = () => { + player.seekForward(10); + if (isSynced && onSyncAction) onSyncAction('seekForward', 10); + }; + + const currentTime = duration > 0 ? progress * duration : 0; -const Item = (props: ItemProps) => { return ( - props.onPress?.(props.index)} - > + + {isSynced && ( + + SYNCED + + )} - {!props.isActive && ( - - )} - + + + {formatTime(currentTime)} + setIsSeeking(true)} + onSlidingComplete={handleSeek} + minimumTrackTintColor={isSynced ? '#00FF00' : '#FFFFFF'} + maximumTrackTintColor="rgba(255, 255, 255, 0.3)" + thumbTintColor={isSynced ? '#00FF00' : '#FFFFFF'} + /> + {formatTime(duration)} + + + + -10s + + + {isPaused ? '▶' : '⏸'} + + + +10s + + + {isMuted ? '🔇' : '🔊'} + + + + ); }; -const ItemMemo = React.memo(Item); +const VideoItemMemo = React.memo(VideoItem); // -const items = Array.from(Array(10).keys()); +const items = [0, 1, 2, 3]; // 4 players + +// Viewability configuration +const viewabilityConfig = { + itemVisiblePercentThreshold: 30, + minimumViewTime: 50, +}; // export default function App() { - const { load } = useVideoPlayer(); + const [visibleItems, setVisibleItems] = useState>(new Set([0, 1, 2, 3])); - const [activeIndex, setActiveItem] = useState(0); + // Players for synced control + const player1 = useVideoPlayer(SYNCED_PLAYERS[0]); + const player2 = useVideoPlayer(SYNCED_PLAYERS[1]); - React.useEffect(() => { - load(VideoUrl, false); - }, []); + const handleSyncAction = useCallback((sourceIndex: number, action: 'play' | 'pause' | 'seek' | 'seekForward' | 'seekRewind', value?: number) => { + // Apply action to all synced players except the source + SYNCED_PLAYERS.forEach((playerIndex) => { + if (playerIndex !== sourceIndex) { + const targetPlayer = playerIndex === SYNCED_PLAYERS[0] ? player1 : player2; + switch (action) { + case 'play': + targetPlayer.play(); + break; + case 'pause': + targetPlayer.pause(); + break; + case 'seek': + if (value !== undefined) targetPlayer.seek(value); + break; + case 'seekForward': + if (value !== undefined) targetPlayer.seekForward(value); + break; + case 'seekRewind': + if (value !== undefined) targetPlayer.seekRewind(value); + break; + } + } + }); + }, [player1, player2]); - // + const onViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + const visible = new Set( + viewableItems + .filter((item) => item.isViewable) + .map((item) => item.index as number) + ); + setVisibleItems(visible); + }, + [] + ); - const onItemPress = useCallback((index: number) => { - setActiveItem(index); + const viewabilityConfigCallbackPairs = useRef([ + { viewabilityConfig, onViewableItemsChanged }, + ]); - console.log('onItemPress: ', index); - }, []); + const renderItem = useCallback( + ({ item: index }: { item: number }) => { + const isSynced = SYNCED_PLAYERS.includes(index); + const videoUrl = isSynced ? SYNC_VIDEO_URL : VideoUrls[index % VideoUrls.length]; + + return ( + handleSyncAction(index, action, value)} + /> + ); + }, + [visibleItems, handleSyncAction] + ); return ( index.toString()} - renderItem={({ index }) => ( - - )} + keyExtractor={(item) => item.toString()} + renderItem={renderItem} + viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current} + removeClippedSubviews={false} + maxToRenderPerBatch={5} + windowSize={11} + initialNumToRender={4} /> ); @@ -115,16 +271,65 @@ const styles = StyleSheet.create({ }, itemContainer: { width: '100%', - height: 250, + aspectRatio: 16 / 9, + marginBottom: 10, }, - playerCover: { - width: '100%', - height: '100%', - backgroundColor: 'black', + controlsContainer: { position: 'absolute', + bottom: 0, left: 0, - top: 0, right: 0, - bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + paddingVertical: 8, + paddingHorizontal: 12, + }, + progressContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + slider: { + flex: 1, + height: 40, + marginHorizontal: 8, + }, + timeText: { + color: 'white', + fontSize: 12, + minWidth: 40, + textAlign: 'center', + }, + controls: { + flexDirection: 'row', + justifyContent: 'space-evenly', + alignItems: 'center', + }, + controlButton: { + width: 50, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + controlButtonText: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + }, + syncBadge: { + position: 'absolute', + top: 10, + left: 10, + backgroundColor: '#00FF00', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + zIndex: 10, + }, + syncBadgeText: { + color: 'black', + fontSize: 10, + fontWeight: 'bold', }, }); diff --git a/example/yarn.lock b/example/yarn.lock index b748e9c..8b4d65a 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -879,6 +879,11 @@ sudo-prompt "^9.0.0" wcwidth "^1.0.1" +"@react-native-community/slider@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@react-native-community/slider/-/slider-3.0.3.tgz#830167fd757ba70ac638747ba3169b2dbae60330" + integrity sha512-8IeHfDwJ9/CTUwFs6x90VlobV3BfuPgNLjTgC6dRZovfCWigaZwVNIFFJnHBakK3pW2xErAPwhdvNR4JeNoYbw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" diff --git a/src/index.tsx b/src/index.tsx index e7c26c5..fe8095a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,7 +4,6 @@ import { NativeModules, requireNativeComponent, } from 'react-native'; -import {} from 'react-native'; // - - - - - - @@ -21,7 +20,11 @@ type StandaloneVideoPlayerType = { isSilent: boolean ): void; - setVolume(volume: number): void; + setVolume(instance: number, volume: number): void; + + getMuted(instance: number): Promise; + + setMuted(instance: number, muted: boolean): void; seek(instance: number, position: number): void; @@ -38,7 +41,7 @@ type StandaloneVideoPlayerType = { getDuration(instance: number): Promise; getProgress(instance: number): Promise; - clear(): void; + clear?(): void; }; const PlayerVideoManager = StandaloneVideoPlayer as StandaloneVideoPlayerType; @@ -189,6 +192,24 @@ function useVideoPlayer(playerInstance = 0) { return CurrentVideoId[playerInstance]; }, [playerInstance]); + const setVolume = useCallback( + (volume: number) => { + PlayerVideoManager.setVolume(playerInstance, volume); + }, + [playerInstance] + ); + + const setMuted = useCallback( + (muted: boolean) => { + PlayerVideoManager.setMuted(playerInstance, muted); + }, + [playerInstance] + ); + + const getMuted = useCallback((): Promise => { + return PlayerVideoManager.getMuted(playerInstance); + }, [playerInstance]); + return useMemo( () => ({ play, @@ -199,8 +220,11 @@ function useVideoPlayer(playerInstance = 0) { seekForward, seekRewind, getCurrentVideoId, + setVolume, + setMuted, + getMuted, }), - [getCurrentVideoId, load, pause, play, seek, seekForward, seekRewind, stop] + [getCurrentVideoId, getMuted, load, pause, play, seek, seekForward, seekRewind, setMuted, setVolume, stop] ); }