diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 680e213f..fe81100c 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -8,7 +8,7 @@ PODS: - hermes-engine (0.77.2): - hermes-engine/Pre-built (= 0.77.2) - hermes-engine/Pre-built (0.77.2) - - NitroModules (0.25.2): + - NitroModules (0.26.2): - DoubleConversion - glog - hermes-engine @@ -1816,7 +1816,7 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8 hermes-engine: 8eb265241fa1d7095d3a40d51fd90f7dce68217c - NitroModules: 5cc92d3b0baf1124ed8136a015924eea03db912e + NitroModules: 39248e88212416d858a4f4561cf719c6cc8ef900 RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 RCTDeprecation: 85b72250b63cfb54f29ca96ceb108cb9ef3c2079 RCTRequired: 567cb8f5d42b990331bfd93faad1d8999b1c1736 diff --git a/example/patches/react-native-nitro-modules+0.25.2.patch b/example/patches/react-native-nitro-modules+0.26.2.patch similarity index 100% rename from example/patches/react-native-nitro-modules+0.25.2.patch rename to example/patches/react-native-nitro-modules+0.26.2.patch diff --git a/example/src/App.tsx b/example/src/App.tsx index fd06bdb8..36dff819 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -136,6 +136,22 @@ const VideoDemo = () => { [addEvent] ); + const handlePlayerSeek = React.useCallback( + (time: number) => { + addEvent(`Player: onSeek ${time.toFixed(2)}s`); + }, + [addEvent] + ); + + const handlePlayerStateChange = React.useCallback( + (state: { isPlaying: boolean; isBuffering: boolean }) => { + addEvent( + `Player: onPlaybackStateChange isPlaying=${state.isPlaying}, isBuffering=${state.isBuffering}` + ); + }, + [addEvent] + ); + // Setup player const player = useVideoPlayer( { @@ -157,6 +173,8 @@ const VideoDemo = () => { useEvent(player, 'onBuffer', handlePlayerBuffer); useEvent(player, 'onProgress', handlePlayerProgress); useEvent(player, 'onStatusChange', handlePlayerStatusChange); + useEvent(player, 'onSeek', handlePlayerSeek); + useEvent(player, 'onPlaybackStateChange', handlePlayerStateChange); // Sync settings with player React.useEffect(() => { diff --git a/packages/react-native-video/ios/core/VideoPlayerObserver.swift b/packages/react-native-video/ios/core/VideoPlayerObserver.swift index 94ca61a0..a69c1715 100644 --- a/packages/react-native-video/ios/core/VideoPlayerObserver.swift +++ b/packages/react-native-video/ios/core/VideoPlayerObserver.swift @@ -11,6 +11,7 @@ import AVFoundation protocol VideoPlayerObserverDelegate: AnyObject { func onPlayedToEnd(player: AVPlayer) func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?) + func onPlayerItemWillChange(hasNewPlayerItem: Bool) func onTextTrackDataChanged(texts: [NSAttributedString]) func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) func onRateChanged(rate: Float) @@ -28,6 +29,7 @@ protocol VideoPlayerObserverDelegate: AnyObject { extension VideoPlayerObserverDelegate { func onPlayedToEnd(player: AVPlayer) {} func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?) {} + func onPlayerItemWillChange(hasNewPlayerItem: Bool) {} func onTextTrackDataChanged(texts: [NSAttributedString]) {} func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) {} func onRateChanged(rate: Float) {} @@ -62,6 +64,7 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP var playbackEndedObserver: NSObjectProtocol? var playbackBufferEmptyObserver: NSKeyValueObservation? var playbackLikelyToKeepUpObserver: NSKeyValueObservation? + var playbackBufferFullObserver: NSKeyValueObservation? var playerItemStatusObserver: NSKeyValueObservation? var playerItemAccessLogObserver: NSObjectProtocol? @@ -144,15 +147,7 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP self?.onPlayerAccessLog(playerItem: playerItem) } - playbackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, change in - guard change.newValue == true else { return } - self?.delegate?.onPlaybackBufferEmpty() - } - - playbackLikelyToKeepUpObserver = playerItem.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] _, change in - guard change.newValue == true else { return } - self?.delegate?.onPlaybackLikelyToKeepUp() - } + setupBufferObservers(for: playerItem) playerItemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] _, change in self?.delegate?.onPlayerItemStatusChanged(status: playerItem.status) @@ -178,10 +173,7 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP self.playerItemAccessLogObserver = nil } // Invalidate KVO observers - playbackBufferEmptyObserver?.invalidate() - playbackBufferEmptyObserver = nil - playbackLikelyToKeepUpObserver?.invalidate() - playbackLikelyToKeepUpObserver = nil + clearBufferObservers() playerItemStatusObserver?.invalidate() playerItemStatusObserver = nil // Remove outputs if needed @@ -241,6 +233,9 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP // Remove observers for old player item invalidatePlayerItemObservers() + // Notify delegate about player item state change + delegate?.onPlayerItemWillChange(hasNewPlayerItem: newPlayerItem != nil) + if let playerItem = newPlayerItem { // Initialize observers for new player item initializePlayerItemObservers(player: player, playerItem: playerItem) @@ -256,4 +251,44 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP delegate?.onBandwidthUpdate(bitrate: lastEvent.indicatedBitrate) } + + // MARK: - Buffer State Management + + func setupBufferObservers(for playerItem: AVPlayerItem) { + clearBufferObservers() + + // Observe buffer empty - this indicates definite buffering + playbackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new, .initial]) { [weak self] playerItem, change in + let isEmpty = change.newValue ?? playerItem.isPlaybackBufferEmpty + if isEmpty { + self?.delegate?.onPlaybackBufferEmpty() + } + } + + // Observe likely to keep up - this indicates that buffering has finished + playbackLikelyToKeepUpObserver = playerItem.observe(\.isPlaybackLikelyToKeepUp, options: [.new, .initial]) { [weak self] playerItem, change in + let isLikelyToKeepUp = change.newValue ?? playerItem.isPlaybackLikelyToKeepUp + if isLikelyToKeepUp { + self?.delegate?.onPlaybackLikelyToKeepUp() + } + } + + // Observe buffer full as an additional signal + playbackBufferFullObserver = playerItem.observe(\.isPlaybackBufferFull, options: [.new, .initial]) { [weak self] playerItem, change in + let isFull = change.newValue ?? playerItem.isPlaybackBufferFull + if isFull { + self?.delegate?.onPlaybackLikelyToKeepUp() + } + } + } + + func clearBufferObservers() { + playbackBufferEmptyObserver?.invalidate() + playbackBufferFullObserver?.invalidate() + playbackLikelyToKeepUpObserver?.invalidate() + + playbackBufferEmptyObserver = nil + playbackBufferFullObserver = nil + playbackLikelyToKeepUpObserver = nil + } } diff --git a/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift b/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift index 785d8675..67263e33 100644 --- a/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift +++ b/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift @@ -22,6 +22,7 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate { func onRateChanged(rate: Float) { eventEmitter.onPlaybackRateChange(Double(rate)) + updateAndEmitPlaybackState() } func onVolumeChanged(volume: Float) { @@ -29,28 +30,21 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate { } func onPlaybackBufferEmpty() { - if playerItem?.isPlaybackBufferEmpty == true { - eventEmitter.onBuffer(true) - status = .loading - } + isCurrentlyBuffering = true + status = .loading + updateAndEmitPlaybackState() } - func onProgressUpdate(currentTime: Double, bufferDuration bufferDuration: Double) { + func onProgressUpdate(currentTime: Double, bufferDuration: Double) { eventEmitter.onProgress(.init(currentTime: currentTime, bufferDuration: bufferDuration)) } func onPlaybackLikelyToKeepUp() { - guard let playerItem else { - return - } - - if !playerItem.isPlaybackBufferEmpty && playerItem.isPlaybackBufferEmpty { - eventEmitter.onBuffer(true) - status = .loading - } else if playerItem.isPlaybackLikelyToKeepUp { - eventEmitter.onBuffer(false) + isCurrentlyBuffering = false + if playerPointer.timeControlStatus == .playing { status = .readytoplay } + updateAndEmitPlaybackState() } func onExternalPlaybackActiveChanged(isActive: Bool) { @@ -58,62 +52,67 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate { } func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus) { - // check for error if playerPointer.status == .failed || playerItem?.status == .failed { self.status = .error + isCurrentlyBuffering = false eventEmitter.onPlaybackStateChange(.init(isPlaying: false, isBuffering: false)) return } - // check if player is waiting to play at specified rate - if playerPointer.timeControlStatus == .waitingToPlayAtSpecifiedRate { + switch status { + case .waitingToPlayAtSpecifiedRate: + isCurrentlyBuffering = true self.status = .loading - return - } - - self.status = .readytoplay - - // check for playback state - switch playerPointer.timeControlStatus { + break + case .playing: - eventEmitter.onPlaybackStateChange(.init(isPlaying: true, isBuffering: playerItem?.isPlaybackBufferEmpty == true)) + isCurrentlyBuffering = false + self.status = .readytoplay + break + case .paused: - eventEmitter.onPlaybackStateChange(.init(isPlaying: false, isBuffering: playerItem?.isPlaybackBufferEmpty == true)) - default: + isCurrentlyBuffering = false + self.status = .readytoplay + break + + @unknown default: break } + + updateAndEmitPlaybackState() } func onPlayerStatusChanged(status: AVPlayer.Status) { - // check for error if status == .failed || playerItem?.status == .failed { self.status = .error + isCurrentlyBuffering = false + updateAndEmitPlaybackState() } } func onPlayerItemStatusChanged(status: AVPlayerItem.Status) { if status == .failed { self.status = .error + isCurrentlyBuffering = false + updateAndEmitPlaybackState() return } switch status { case .unknown: + isCurrentlyBuffering = true self.status = .loading - case .readyToPlay: - self.status = playerItem?.isPlaybackBufferEmpty == true ? .loading : .readytoplay - case .failed: - self.status = .error - @unknown default: - break - } - - if self.status == .error || self.status == .readytoplay { - guard let playerItem else { - // unlikely to happen - return + + // Set initial buffering state when we have a playerItem + if let playerItem = self.playerItem { + if playerItem.isPlaybackBufferEmpty { + isCurrentlyBuffering = true + } } + case .readyToPlay: + guard let playerItem else { return } + let height = playerItem.presentationSize.height let width = playerItem.presentationSize.width let orientation: VideoOrientation = playerItem.asset.tracks.first(where: { $0.mediaType == .video })?.orientation ?? .unknown @@ -121,7 +120,21 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate { eventEmitter.onLoad( .init(currentTime, duration, height, width, orientation) ) + + if playerItem.isPlaybackLikelyToKeepUp && !playerItem.isPlaybackBufferEmpty { + isCurrentlyBuffering = false + self.status = .readytoplay + } + + case .failed: + self.status = .error + isCurrentlyBuffering = false + + @unknown default: + break } + + updateAndEmitPlaybackState() } func onTextTrackDataChanged(texts: [NSAttributedString]) { @@ -145,4 +158,23 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate { func onBandwidthUpdate(bitrate: Double) { eventEmitter.onBandwidthUpdate(.init(bitrate: bitrate, width: nil, height: nil)) } + + func onPlayerItemWillChange(hasNewPlayerItem: Bool) { + if hasNewPlayerItem { + // Set initial buffering state when playerItem is assigned + isCurrentlyBuffering = true + status = .loading + updateAndEmitPlaybackState() + } else { + // Clean up state when playerItem is cleared + isCurrentlyBuffering = false + } + } + + func updateAndEmitPlaybackState() { + let isPlaying = playerPointer.rate > 0 && !isCurrentlyBuffering + + eventEmitter.onPlaybackStateChange(.init(isPlaying: isPlaying, isBuffering: isCurrentlyBuffering)) + eventEmitter.onBuffer(isCurrentlyBuffering) + } } diff --git a/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift b/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift index 822d6c4a..7e63eeb9 100644 --- a/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift +++ b/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift @@ -169,6 +169,9 @@ class HybridVideoPlayer: HybridVideoPlayerSpec { // Text track selection state private var selectedExternalTrackIndex: Int? = nil + + // MARK: - Buffering state tracking + var isCurrentlyBuffering: Bool = false var isPlaying: Bool { get { @@ -185,6 +188,7 @@ class HybridVideoPlayer: HybridVideoPlayerSpec { func release() { playerQueue.async { [weak self] in guard let self = self else { return } + self.player?.replaceCurrentItem(with: nil) self.player = nil self.playerItem = nil @@ -295,7 +299,7 @@ class HybridVideoPlayer: HybridVideoPlayerSpec { return promise } - // MARK: - Internal Methods + // MARK: - Methods /** * Initialize the player item synchronously. This is used to initialize the player item before it is set to the player.