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/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 c8f8ff1..5e75575 100644 --- a/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt +++ b/android/src/main/java/com/reactnativestandalonevideoplayer/PlayerVideo.kt @@ -3,38 +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.ExoPlaybackException +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.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.source.ProgressiveMediaSource 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.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() } + + val instanceCount: Int + get() = synchronized(instancesLock) { _instances.size } + + fun addInstance(instance: PlayerVideo) = synchronized(instancesLock) { + _instances.add(instance) + } + + fun getInstance(index: Int): PlayerVideo? = synchronized(instancesLock) { + _instances.getOrNull(index) + } + + 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 } + } + } + } 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 = 1000 + 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() - val player = SimpleExoPlayer.Builder(context).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 @@ -44,159 +117,238 @@ 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}") + if (isReleased) { + Log.w(TAG, "Cannot load video - player is released") + return + } - // Produces DataSource instances through which media data is loaded. - val dataSourceFactory: DataSource.Factory = DefaultDataSourceFactory( - context, - Util.getUserAgent(context, context.packageName) - ) + 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(Uri.parse(url)) + // Use shared DataSource factory for better performance + val dataSourceFactory = getDataSourceFactory(context) - val httpMediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(Uri.parse(url)) + 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.prepare(mediaSource) + player.setMediaSource(mediaSource) + player.prepare() 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.EventListener { - override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { - Log.d("PlayerVideo", "onPlayerStateChanged = ${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() + } } } - } - }) - 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 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() } @@ -204,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) @@ -218,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 } } @@ -253,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/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))}\\/.*$`) 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] ); }