diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt index 473f964..f37aff9 100644 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt @@ -41,6 +41,11 @@ sealed class PlayerEvent { override val name = "playToEnd" } + data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() { + override val name = "timeRemainingChange" + override val arguments = arrayOf(timeRemaining) + } + fun emit(player: VideoPlayer, listeners: List) { when (this) { is StatusChanged -> listeners.forEach { it.onStatusChanged(player, status, oldStatus, error) } @@ -49,6 +54,7 @@ sealed class PlayerEvent { is SourceChanged -> listeners.forEach { it.onSourceChanged(player, source, oldSource) } is PlaybackRateChanged -> listeners.forEach { it.onPlaybackRateChanged(player, rate, oldRate) } is PlayedToEnd -> listeners.forEach { it.onPlayedToEnd(player) } + is PlayerTimeRemainingChanged -> listeners.forEach { it.onPlayerTimeRemainingChanged(player, timeRemaining) } } } } diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt index 9905e13..47342ff 100644 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt @@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole setShowPreviousButton(!requireLinearPlayback) setShowNextButton(!requireLinearPlayback) setTimeBarInteractive(requireLinearPlayback) + setShowSubtitleButton(true) } @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) { @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) { - val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen) + val fullscreenButton = + findViewById(androidx.media3.ui.R.id.exo_fullscreen) fullscreenButton?.visibility = if (visible) { android.view.View.VISIBLE } else { diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt new file mode 100644 index 0000000..0249e23 --- /dev/null +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt @@ -0,0 +1,29 @@ +import android.os.Handler +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import expo.modules.video.PlayerEvent +import expo.modules.video.VideoPlayer +import kotlin.math.floor + +@OptIn(UnstableApi::class) +class ProgressTracker(private val videoPlayer: VideoPlayer) : Runnable { + private val handler: Handler = Handler(Looper.getMainLooper()) + private val player = videoPlayer.player + + init { + handler.post(this) + } + + override fun run() { + val currentPosition = player.currentPosition + val duration = player.duration + val timeRemaining = floor(((duration - currentPosition) / 1000).toDouble()) + videoPlayer.sendEvent(PlayerEvent.PlayerTimeRemainingChanged(timeRemaining)) + handler.postDelayed(this, 1000 /* ms */) + } + + fun remove() { + handler.removeCallbacks(this) + } +} \ No newline at end of file diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt index 4b6c6d8..e20f51a 100644 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt @@ -1,5 +1,6 @@ package expo.modules.video +import android.provider.MediaStore.Video import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import expo.modules.kotlin.AppContext @@ -15,6 +16,8 @@ object VideoManager { // Keeps track of all existing VideoPlayers, and whether they are attached to a VideoView private var videoPlayersToVideoViews = mutableMapOf>() + private var previouslyPlayingViews: MutableList? = null + private lateinit var audioFocusManager: AudioFocusManager fun onModuleCreated(appContext: AppContext) { @@ -69,16 +72,24 @@ object VideoManager { return videoPlayersToVideoViews[videoPlayer]?.isNotEmpty() ?: false } - fun onAppForegrounded() = Unit + fun onAppForegrounded() { + val previouslyPlayingViews = this.previouslyPlayingViews ?: return + for (videoView in previouslyPlayingViews) { + val player = videoView.videoPlayer?.player ?: continue + player.play() + } + this.previouslyPlayingViews = null + } fun onAppBackgrounded() { + val previouslyPlayingViews = mutableListOf() for (videoView in videoViews.values) { - if (videoView.videoPlayer?.staysActiveInBackground == false && - !videoView.willEnterPiP && - !videoView.isInFullscreen - ) { - videoView.videoPlayer?.player?.pause() + val player = videoView.videoPlayer?.player ?: continue + if (player.isPlaying) { + player.pause() + previouslyPlayingViews.add(videoView) } } + this.previouslyPlayingViews = previouslyPlayingViews } } diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt index ec3da2a..5a1397a 100644 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt @@ -43,7 +43,9 @@ class VideoModule : Module() { View(VideoView::class) { Events( "onPictureInPictureStart", - "onPictureInPictureStop" + "onPictureInPictureStop", + "onEnterFullscreen", + "onExitFullscreen" ) Prop("player") { view: VideoView, player: VideoPlayer -> diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt index 58f00af..5ad8237 100644 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt @@ -1,5 +1,6 @@ package expo.modules.video +import ProgressTracker import android.content.Context import android.view.SurfaceView import androidx.media3.common.MediaItem @@ -35,11 +36,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou .Builder(context, renderersFactory) .setLooper(context.mainLooper) .build() + var progressTracker: ProgressTracker? = null val serviceConnection = PlaybackServiceConnection(WeakReference(player)) var playing by IgnoreSameSet(false) { new, old -> sendEvent(PlayerEvent.IsPlayingChanged(new, old)) + addOrRemoveProgressTracker() } var uncommittedSource: VideoSource? = source @@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou } override fun close() { + this.progressTracker?.remove() + this.progressTracker = null + appContext?.reactContext?.unbindService(serviceConnection) serviceConnection.playbackServiceBinder?.service?.unregisterPlayer(player) VideoManager.unregisterVideoPlayer(this@VideoPlayer) @@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou listeners.removeAll { it.get() == videoPlayerListener } } - private fun sendEvent(event: PlayerEvent) { + fun sendEvent(event: PlayerEvent) { // Emits to the native listeners event.emit(this, listeners.mapNotNull { it.get() }) // Emits to the JS side @@ -240,4 +246,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou sendEvent(eventName, *args) } } + + private fun addOrRemoveProgressTracker() { + this.progressTracker?.remove() + if (this.playing) { + this.progressTracker = ProgressTracker(this) + } else { + this.progressTracker = null + } + } } diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt index f654254..dcfe3f0 100644 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt @@ -15,4 +15,5 @@ interface VideoPlayerListener { fun onSourceChanged(player: VideoPlayer, source: VideoSource?, oldSource: VideoSource?) {} fun onPlaybackRateChanged(player: VideoPlayer, rate: Float, oldRate: Float?) {} fun onPlayedToEnd(player: VideoPlayer) {} + fun onPlayerTimeRemainingChanged(player: VideoPlayer, timeRemaining: Double) {} } diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt index a951d80..3932535 100644 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt @@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap val playerView: PlayerView = PlayerView(context.applicationContext) val onPictureInPictureStart by EventDispatcher() val onPictureInPictureStop by EventDispatcher() + val onEnterFullscreen by EventDispatcher() + val onExitFullscreen by EventDispatcher() var willEnterPiP: Boolean = false var isInFullscreen: Boolean = false @@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap @Suppress("DEPRECATION") currentActivity.overridePendingTransition(0, 0) } + onEnterFullscreen(mapOf()) isInFullscreen = true } @@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen) fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter) videoPlayer?.changePlayerView(playerView) + this.onExitFullscreen(mapOf()) isInFullscreen = false } diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts index a09fcfe..5eac9e5 100644 --- a/node_modules/expo-video/build/VideoPlayer.types.d.ts +++ b/node_modules/expo-video/build/VideoPlayer.types.d.ts @@ -128,6 +128,8 @@ export type VideoPlayerEvents = { * Handler for an event emitted when the current media source of the player changes. */ sourceChange(newSource: VideoSource, previousSource: VideoSource): void; + + timeRemainingChange(timeRemaining: number): void; }; /** * Describes the current status of the player. diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts index cb9ca6d..ed8bb7e 100644 --- a/node_modules/expo-video/build/VideoView.types.d.ts +++ b/node_modules/expo-video/build/VideoView.types.d.ts @@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps { * @platform ios 16.0+ */ allowsVideoFrameAnalysis?: boolean; + + onEnterFullscreen?: () => void; + onExitFullscreen?: () => void; } //# sourceMappingURL=VideoView.types.d.ts.map \ No newline at end of file diff --git a/node_modules/expo-video/ios/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift index 094a8b0..3f00525 100644 --- a/node_modules/expo-video/ios/VideoManager.swift +++ b/node_modules/expo-video/ios/VideoManager.swift @@ -12,6 +12,7 @@ class VideoManager { private var videoViews = NSHashTable.weakObjects() private var videoPlayers = NSHashTable.weakObjects() + private var previouslyPlayingPlayers: [VideoPlayer]? func register(videoPlayer: VideoPlayer) { videoPlayers.add(videoPlayer) @@ -33,63 +34,70 @@ class VideoManager { for videoPlayer in videoPlayers.allObjects { videoPlayer.setTracksEnabled(true) } + + if let previouslyPlayingPlayers = self.previouslyPlayingPlayers { + previouslyPlayingPlayers.forEach { player in + player.pointer.play() + } + } } func onAppBackgrounded() { + var previouslyPlayingPlayers: [VideoPlayer] = [] for videoView in videoViews.allObjects { guard let player = videoView.player else { continue } - if player.staysActiveInBackground == true { - player.setTracksEnabled(videoView.isInPictureInPicture) - } else if !videoView.isInPictureInPicture { + if player.isPlaying { player.pointer.pause() + previouslyPlayingPlayers.append(player) } } + self.previouslyPlayingPlayers = previouslyPlayingPlayers } // MARK: - Audio Session Management internal func setAppropriateAudioSessionOrWarn() { - let audioSession = AVAudioSession.sharedInstance() - var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = [] - - let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in - player.isPlaying - } - let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in - player.isMuted - } - let needsPiPSupport = videoViews.allObjects.contains { view in - view.allowPictureInPicture - } - let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in - player.showNowPlayingNotification - } - // The notification won't be shown if we allow the audio to mix with others - let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification - let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying - let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing - - if shouldAllowMixing { - audioSessionCategoryOptions.insert(.mixWithOthers) - } - - if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification { - do { - try audioSession.setCategory(.playback, mode: .moviePlayback) - } catch { - log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)") - } - } - - // Make sure audio session is active if any video is playing - if isAnyPlayerPlaying { - do { - try audioSession.setActive(true) - } catch { - log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)") - } - } +// let audioSession = AVAudioSession.sharedInstance() +// var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = [] +// +// let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in +// player.isPlaying +// } +// let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in +// player.isMuted +// } +// let needsPiPSupport = videoViews.allObjects.contains { view in +// view.allowPictureInPicture +// } +// let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in +// player.showNowPlayingNotification +// } +// // The notification won't be shown if we allow the audio to mix with others +// let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification +// let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying +// let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing +// +// if shouldAllowMixing { +// audioSessionCategoryOptions.insert(.mixWithOthers) +// } +// +// if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification { +// do { +// try audioSession.setCategory(.playback, mode: .moviePlayback) +// } catch { +// log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)") +// } +// } +// +// // Make sure audio session is active if any video is playing +// if isAnyPlayerPlaying { +// do { +// try audioSession.setActive(true) +// } catch { +// log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)") +// } +// } } } diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift index c537a12..e4a918f 100644 --- a/node_modules/expo-video/ios/VideoModule.swift +++ b/node_modules/expo-video/ios/VideoModule.swift @@ -16,7 +16,9 @@ public final class VideoModule: Module { View(VideoView.self) { Events( "onPictureInPictureStart", - "onPictureInPictureStop" + "onPictureInPictureStop", + "onEnterFullscreen", + "onExitFullscreen" ) Prop("player") { (view, player: VideoPlayer?) in diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift index 3315b88..733ab1f 100644 --- a/node_modules/expo-video/ios/VideoPlayer.swift +++ b/node_modules/expo-video/ios/VideoPlayer.swift @@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef, Hashable, VideoPlayerObse safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource) } + func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) { + safeEmit(event: "timeRemainingChange", arguments: timeRemaining) + } + func safeEmit(event: String, arguments: repeat each A) { if self.appContext != nil { self.emit(event: event, arguments: repeat each arguments) diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift index d289e26..ea4d96f 100644 --- a/node_modules/expo-video/ios/VideoPlayerObserver.swift +++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift @@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject { func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) + func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) } // Default implementations for the delegate @@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate { func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {} func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) {} func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {} + func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {} } // Wrapper used to store WeakReferences to the observer delegate @@ -91,6 +93,7 @@ class VideoPlayerObserver { private var playerVolumeObserver: NSKeyValueObservation? private var playerCurrentItemObserver: NSKeyValueObservation? private var playerIsMutedObserver: NSKeyValueObservation? + private var playerPeriodicTimeObserver: Any? // Current player item observers private var playbackBufferEmptyObserver: NSKeyValueObservation? @@ -152,6 +155,9 @@ class VideoPlayerObserver { playerVolumeObserver?.invalidate() playerIsMutedObserver?.invalidate() playerCurrentItemObserver?.invalidate() + if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver { + player?.removeTimeObserver(playerPeriodicTimeObserver) + } } private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) { @@ -270,6 +276,7 @@ class VideoPlayerObserver { if isPlaying != (player.timeControlStatus == .playing) { isPlaying = player.timeControlStatus == .playing + addPeriodicTimeObserverIfNeeded() } } @@ -310,4 +317,28 @@ class VideoPlayerObserver { } } } + + private func onPlayerTimeRemainingChanged(_ player: AVPlayer, _ timeRemaining: Double) { + delegates.forEach { delegate in + delegate.value?.onPlayerTimeRemainingChanged(player: player, timeRemaining: timeRemaining) + } + } + + private func addPeriodicTimeObserverIfNeeded() { + guard self.playerPeriodicTimeObserver == nil, let player = self.player else { + return + } + + if isPlaying { + // Add the time update listener + playerPeriodicTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { event in + guard let duration = player.currentItem?.duration else { + return + } + + let timeRemaining = (duration.seconds - event.seconds).rounded() + self.onPlayerTimeRemainingChanged(player, timeRemaining) + } + } + } } diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift index f4579e4..10c5908 100644 --- a/node_modules/expo-video/ios/VideoView.swift +++ b/node_modules/expo-video/ios/VideoView.swift @@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { let onPictureInPictureStart = EventDispatcher() let onPictureInPictureStop = EventDispatcher() + let onEnterFullscreen = EventDispatcher() + let onExitFullscreen = EventDispatcher() public override var bounds: CGRect { didSet { @@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { _ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator ) { + onEnterFullscreen() isFullscreen = true } @@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { if wasPlaying { self.player?.pointer.play() } + self.onExitFullscreen() self.isFullscreen = false } } diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts index aaf4b63..f438196 100644 --- a/node_modules/expo-video/src/VideoPlayer.types.ts +++ b/node_modules/expo-video/src/VideoPlayer.types.ts @@ -151,6 +151,8 @@ export type VideoPlayerEvents = { * Handler for an event emitted when the current media source of the player changes. */ sourceChange(newSource: VideoSource, previousSource: VideoSource): void; + + timeRemainingChange(timeRemaining: number): void; }; /** diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts index 29fe5db..e1fbf59 100644 --- a/node_modules/expo-video/src/VideoView.types.ts +++ b/node_modules/expo-video/src/VideoView.types.ts @@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps { * @platform ios 16.0+ */ allowsVideoFrameAnalysis?: boolean; + + onEnterFullscreen?: () => void; + onExitFullscreen?: () => void; }