Bluesky app fork with some witchin' additions 💫

[Video] Add `timeRemainingChange` event to `player` in `expo-video` (#5013)

authored by hailey.at and committed by

GitHub d52d2962 d92731b1

+296 -59
+258 -11
patches/expo-video+1.2.4.patch
··· 1 + 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 2 + index 473f964..f37aff9 100644 3 + --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt 4 + +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt 5 + @@ -41,6 +41,11 @@ sealed class PlayerEvent { 6 + override val name = "playToEnd" 7 + } 8 + 9 + + data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() { 10 + + override val name = "timeRemainingChange" 11 + + override val arguments = arrayOf(timeRemaining) 12 + + } 13 + + 14 + fun emit(player: VideoPlayer, listeners: List<VideoPlayerListener>) { 15 + when (this) { 16 + is StatusChanged -> listeners.forEach { it.onStatusChanged(player, status, oldStatus, error) } 17 + @@ -49,6 +54,7 @@ sealed class PlayerEvent { 18 + is SourceChanged -> listeners.forEach { it.onSourceChanged(player, source, oldSource) } 19 + is PlaybackRateChanged -> listeners.forEach { it.onPlaybackRateChanged(player, rate, oldRate) } 20 + is PlayedToEnd -> listeners.forEach { it.onPlayedToEnd(player) } 21 + + is PlayerTimeRemainingChanged -> listeners.forEach { it.onPlayerTimeRemainingChanged(player, timeRemaining) } 22 + } 23 + } 24 + } 1 25 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 2 26 index 9905e13..47342ff 100644 3 27 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt ··· 8 32 setTimeBarInteractive(requireLinearPlayback) 9 33 + setShowSubtitleButton(true) 10 34 } 11 - 35 + 12 36 @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) 13 37 @@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) { 14 - 38 + 15 39 @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) 16 40 internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) { 17 41 - val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen) ··· 20 44 fullscreenButton?.visibility = if (visible) { 21 45 android.view.View.VISIBLE 22 46 } else { 47 + 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 48 + new file mode 100644 49 + index 0000000..0249e23 50 + --- /dev/null 51 + +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt 52 + @@ -0,0 +1,29 @@ 53 + +import android.os.Handler 54 + +import android.os.Looper 55 + +import androidx.annotation.OptIn 56 + +import androidx.media3.common.util.UnstableApi 57 + +import expo.modules.video.PlayerEvent 58 + +import expo.modules.video.VideoPlayer 59 + +import kotlin.math.floor 60 + + 61 + +@OptIn(UnstableApi::class) 62 + +class ProgressTracker(private val videoPlayer: VideoPlayer) : Runnable { 63 + + private val handler: Handler = Handler(Looper.getMainLooper()) 64 + + private val player = videoPlayer.player 65 + + 66 + + init { 67 + + handler.post(this) 68 + + } 69 + + 70 + + override fun run() { 71 + + val currentPosition = player.currentPosition 72 + + val duration = player.duration 73 + + val timeRemaining = floor(((duration - currentPosition) / 1000).toDouble()) 74 + + videoPlayer.sendEvent(PlayerEvent.PlayerTimeRemainingChanged(timeRemaining)) 75 + + handler.postDelayed(this, 1000 /* ms */) 76 + + } 77 + + 78 + + fun remove() { 79 + + handler.removeCallbacks(this) 80 + + } 81 + +} 82 + \ No newline at end of file 23 83 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 24 84 index ec3da2a..5a1397a 100644 25 85 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt ··· 33 93 + "onEnterFullscreen", 34 94 + "onExitFullscreen" 35 95 ) 36 - 96 + 37 97 Prop("player") { view: VideoView, player: VideoPlayer -> 98 + 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 99 + index 58f00af..5ad8237 100644 100 + --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt 101 + +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt 102 + @@ -1,5 +1,6 @@ 103 + package expo.modules.video 104 + 105 + +import ProgressTracker 106 + import android.content.Context 107 + import android.view.SurfaceView 108 + import androidx.media3.common.MediaItem 109 + @@ -35,11 +36,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou 110 + .Builder(context, renderersFactory) 111 + .setLooper(context.mainLooper) 112 + .build() 113 + + var progressTracker: ProgressTracker? = null 114 + 115 + val serviceConnection = PlaybackServiceConnection(WeakReference(player)) 116 + 117 + var playing by IgnoreSameSet(false) { new, old -> 118 + sendEvent(PlayerEvent.IsPlayingChanged(new, old)) 119 + + addOrRemoveProgressTracker() 120 + } 121 + 122 + var uncommittedSource: VideoSource? = source 123 + @@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou 124 + } 125 + 126 + override fun close() { 127 + + this.progressTracker?.remove() 128 + + this.progressTracker = null 129 + + 130 + appContext?.reactContext?.unbindService(serviceConnection) 131 + serviceConnection.playbackServiceBinder?.service?.unregisterPlayer(player) 132 + VideoManager.unregisterVideoPlayer(this@VideoPlayer) 133 + @@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou 134 + listeners.removeAll { it.get() == videoPlayerListener } 135 + } 136 + 137 + - private fun sendEvent(event: PlayerEvent) { 138 + + fun sendEvent(event: PlayerEvent) { 139 + // Emits to the native listeners 140 + event.emit(this, listeners.mapNotNull { it.get() }) 141 + // Emits to the JS side 142 + @@ -240,4 +246,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou 143 + sendEvent(eventName, *args) 144 + } 145 + } 146 + + 147 + + private fun addOrRemoveProgressTracker() { 148 + + this.progressTracker?.remove() 149 + + if (this.playing) { 150 + + this.progressTracker = ProgressTracker(this) 151 + + } else { 152 + + this.progressTracker = null 153 + + } 154 + + } 155 + } 156 + 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 157 + index f654254..dcfe3f0 100644 158 + --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt 159 + +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt 160 + @@ -15,4 +15,5 @@ interface VideoPlayerListener { 161 + fun onSourceChanged(player: VideoPlayer, source: VideoSource?, oldSource: VideoSource?) {} 162 + fun onPlaybackRateChanged(player: VideoPlayer, rate: Float, oldRate: Float?) {} 163 + fun onPlayedToEnd(player: VideoPlayer) {} 164 + + fun onPlayerTimeRemainingChanged(player: VideoPlayer, timeRemaining: Double) {} 165 + } 38 166 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 39 167 index a951d80..3932535 100644 40 168 --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt ··· 45 173 val onPictureInPictureStop by EventDispatcher<Unit>() 46 174 + val onEnterFullscreen by EventDispatcher() 47 175 + val onExitFullscreen by EventDispatcher() 48 - 176 + 49 177 var willEnterPiP: Boolean = false 50 178 var isInFullscreen: Boolean = false 51 179 @@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap ··· 55 183 + onEnterFullscreen(mapOf()) 56 184 isInFullscreen = true 57 185 } 58 - 186 + 59 187 @@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap 60 188 val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen) 61 189 fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter) ··· 63 191 + this.onExitFullscreen(mapOf()) 64 192 isInFullscreen = false 65 193 } 66 - 194 + 195 + diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts 196 + index a09fcfe..65fe29a 100644 197 + --- a/node_modules/expo-video/build/VideoPlayer.types.d.ts 198 + +++ b/node_modules/expo-video/build/VideoPlayer.types.d.ts 199 + @@ -128,6 +128,8 @@ export type VideoPlayerEvents = { 200 + * Handler for an event emitted when the current media source of the player changes. 201 + */ 202 + sourceChange(newSource: VideoSource, previousSource: VideoSource): void; 203 + + 204 + + timeRemainingChange(timeRemaining: number): void; 205 + }; 206 + /** 207 + * Describes the current status of the player. 67 208 diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts 68 - index cb9ca6d..60e9f4e 100644 209 + index cb9ca6d..ed8bb7e 100644 69 210 --- a/node_modules/expo-video/build/VideoView.types.d.ts 70 211 +++ b/node_modules/expo-video/build/VideoView.types.d.ts 71 212 @@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps { ··· 77 218 + onExitFullscreen?: () => void; 78 219 } 79 220 //# sourceMappingURL=VideoView.types.d.ts.map 221 + \ No newline at end of file 80 222 diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift 81 223 index c537a12..e4a918f 100644 82 224 --- a/node_modules/expo-video/ios/VideoModule.swift ··· 90 232 + "onEnterFullscreen", 91 233 + "onExitFullscreen" 92 234 ) 93 - 235 + 94 236 Prop("player") { (view, player: VideoPlayer?) in 237 + diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift 238 + index 3315b88..f482390 100644 239 + --- a/node_modules/expo-video/ios/VideoPlayer.swift 240 + +++ b/node_modules/expo-video/ios/VideoPlayer.swift 241 + @@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse 242 + safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource) 243 + } 244 + 245 + + func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) { 246 + + safeEmit(event: "timeRemainingChange", arguments: timeRemaining) 247 + + } 248 + + 249 + func safeEmit<each A: AnyArgument>(event: String, arguments: repeat each A) { 250 + if self.appContext != nil { 251 + self.emit(event: event, arguments: repeat each arguments) 252 + diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift 253 + index d289e26..d0fdd30 100644 254 + --- a/node_modules/expo-video/ios/VideoPlayerObserver.swift 255 + +++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift 256 + @@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject { 257 + func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) 258 + func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) 259 + func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) 260 + + func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) 261 + } 262 + 263 + // Default implementations for the delegate 264 + @@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate { 265 + func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {} 266 + func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) {} 267 + func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {} 268 + + func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {} 269 + } 270 + 271 + // Wrapper used to store WeakReferences to the observer delegate 272 + @@ -91,6 +93,7 @@ class VideoPlayerObserver { 273 + private var playerVolumeObserver: NSKeyValueObservation? 274 + private var playerCurrentItemObserver: NSKeyValueObservation? 275 + private var playerIsMutedObserver: NSKeyValueObservation? 276 + + private var playerPeriodicTimeObserver: Any? 277 + 278 + // Current player item observers 279 + private var playbackBufferEmptyObserver: NSKeyValueObservation? 280 + @@ -152,6 +155,9 @@ class VideoPlayerObserver { 281 + playerVolumeObserver?.invalidate() 282 + playerIsMutedObserver?.invalidate() 283 + playerCurrentItemObserver?.invalidate() 284 + + if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver { 285 + + player?.removeTimeObserver(playerPeriodicTimeObserver) 286 + + } 287 + } 288 + 289 + private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) { 290 + @@ -270,6 +276,7 @@ class VideoPlayerObserver { 291 + 292 + if isPlaying != (player.timeControlStatus == .playing) { 293 + isPlaying = player.timeControlStatus == .playing 294 + + addOrRemovePeriodicTimeObserver() 295 + } 296 + } 297 + 298 + @@ -310,4 +317,30 @@ class VideoPlayerObserver { 299 + } 300 + } 301 + } 302 + + 303 + + private func onPlayerTimeRemainingChanged(_ player: AVPlayer, _ timeRemaining: Double) { 304 + + delegates.forEach { delegate in 305 + + delegate.value?.onPlayerTimeRemainingChanged(player: player, timeRemaining: timeRemaining) 306 + + } 307 + + } 308 + + 309 + + private func addOrRemovePeriodicTimeObserver() { 310 + + guard let player = self.player else { 311 + + return 312 + + } 313 + + 314 + + if isPlaying { 315 + + // Add the time update listener 316 + + playerPeriodicTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { event in 317 + + guard let duration = player.currentItem?.duration else { 318 + + return 319 + + } 320 + + 321 + + let timeRemaining = (duration.seconds - event.seconds).rounded() 322 + + self.onPlayerTimeRemainingChanged(player, timeRemaining) 323 + + } 324 + + } else if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver { 325 + + player.removeTimeObserver(playerPeriodicTimeObserver) 326 + + } 327 + + } 328 + } 95 329 diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift 96 330 index f4579e4..10c5908 100644 97 331 --- a/node_modules/expo-video/ios/VideoView.swift 98 332 +++ b/node_modules/expo-video/ios/VideoView.swift 99 333 @@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { 100 - 334 + 101 335 let onPictureInPictureStart = EventDispatcher() 102 336 let onPictureInPictureStop = EventDispatcher() 103 337 + let onEnterFullscreen = EventDispatcher() 104 338 + let onExitFullscreen = EventDispatcher() 105 - 339 + 106 340 public override var bounds: CGRect { 107 341 didSet { 108 342 @@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { ··· 112 346 + onEnterFullscreen() 113 347 isFullscreen = true 114 348 } 115 - 349 + 116 350 @@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { 117 351 if wasPlaying { 118 352 self.player?.pointer.play() ··· 121 355 self.isFullscreen = false 122 356 } 123 357 } 358 + diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts 359 + index aaf4b63..f438196 100644 360 + --- a/node_modules/expo-video/src/VideoPlayer.types.ts 361 + +++ b/node_modules/expo-video/src/VideoPlayer.types.ts 362 + @@ -151,6 +151,8 @@ export type VideoPlayerEvents = { 363 + * Handler for an event emitted when the current media source of the player changes. 364 + */ 365 + sourceChange(newSource: VideoSource, previousSource: VideoSource): void; 366 + + 367 + + timeRemainingChange(timeRemaining: number): void; 368 + }; 369 + 370 + /** 124 371 diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts 125 372 index 29fe5db..e1fbf59 100644 126 373 --- a/node_modules/expo-video/src/VideoView.types.ts
+38 -48
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
··· 102 102 const {_} = useLingui() 103 103 const t = useTheme() 104 104 const [isMuted, setIsMuted] = useState(player.muted) 105 - const [duration, setDuration] = useState(() => Math.floor(player.duration)) 106 - const [currentTime, setCurrentTime] = useState(() => 107 - Math.floor(player.currentTime), 108 - ) 105 + const [timeRemaining, setTimeRemaining] = React.useState(0) 109 106 110 107 useEffect(() => { 111 - const interval = setInterval(() => { 112 - // duration gets reset to 0 on loop 113 - if (player.duration) setDuration(Math.floor(player.duration)) 114 - setCurrentTime(Math.floor(player.currentTime)) 115 - 116 - // how often should we update the time? 117 - // 1000 gets out of sync with the video time 118 - }, 250) 119 - 120 108 // eslint-disable-next-line @typescript-eslint/no-shadow 121 - const sub = player.addListener('volumeChange', ({isMuted}) => { 109 + const volumeSub = player.addListener('volumeChange', ({isMuted}) => { 122 110 setIsMuted(isMuted) 123 111 }) 124 - 112 + const timeSub = player.addListener( 113 + 'timeRemainingChange', 114 + secondsRemaining => { 115 + setTimeRemaining(secondsRemaining) 116 + }, 117 + ) 125 118 return () => { 126 - clearInterval(interval) 127 - sub.remove() 119 + volumeSub.remove() 120 + timeSub.remove() 128 121 } 129 122 }, [player]) 130 123 ··· 160 153 // 1. timeRemaining is a number - was seeing NaNs 161 154 // 2. duration is greater than 0 - means metadata has loaded 162 155 // 3. we're less than 5 second into the video 163 - const timeRemaining = duration - currentTime 164 - const showTime = !isNaN(timeRemaining) && duration > 0 && currentTime <= 5 156 + const showTime = !isNaN(timeRemaining) 165 157 166 158 return ( 167 159 <View style={[a.absolute, a.inset_0]}> ··· 173 165 accessibilityHint={_(msg`Tap to enter full screen`)} 174 166 accessibilityRole="button" 175 167 /> 176 - {duration > 0 && ( 177 - <Animated.View 178 - entering={FadeInDown.duration(300)} 179 - style={{ 180 - backgroundColor: 'rgba(0, 0, 0, 0.5)', 181 - borderRadius: 6, 182 - paddingHorizontal: 6, 183 - paddingVertical: 3, 184 - position: 'absolute', 185 - bottom: 5, 186 - right: 5, 187 - minHeight: 20, 188 - justifyContent: 'center', 189 - }}> 190 - <Pressable 191 - onPress={toggleMuted} 192 - style={a.flex_1} 193 - accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)} 194 - accessibilityHint={_(msg`Tap to toggle sound`)} 195 - accessibilityRole="button" 196 - hitSlop={HITSLOP_30}> 197 - {isMuted ? ( 198 - <MuteIcon width={14} fill={t.palette.white} /> 199 - ) : ( 200 - <UnmuteIcon width={14} fill={t.palette.white} /> 201 - )} 202 - </Pressable> 203 - </Animated.View> 204 - )} 168 + <Animated.View 169 + entering={FadeInDown.duration(300)} 170 + style={{ 171 + backgroundColor: 'rgba(0, 0, 0, 0.5)', 172 + borderRadius: 6, 173 + paddingHorizontal: 6, 174 + paddingVertical: 3, 175 + position: 'absolute', 176 + bottom: 5, 177 + right: 5, 178 + minHeight: 20, 179 + justifyContent: 'center', 180 + }}> 181 + <Pressable 182 + onPress={toggleMuted} 183 + style={a.flex_1} 184 + accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)} 185 + accessibilityHint={_(msg`Tap to toggle sound`)} 186 + accessibilityRole="button" 187 + hitSlop={HITSLOP_30}> 188 + {isMuted ? ( 189 + <MuteIcon width={14} fill={t.palette.white} /> 190 + ) : ( 191 + <UnmuteIcon width={14} fill={t.palette.white} /> 192 + )} 193 + </Pressable> 194 + </Animated.View> 205 195 </View> 206 196 ) 207 197 }