fix(ios): Improves playback state and buffering events (#18)

Co-authored-by: Pieczasz <bartekp854@gmail.com>
Co-authored-by: Krzysztof Moch <krzysmoch.programs@gmail.com>
This commit is contained in:
pieczasz-thewidlarzgroup
2025-06-25 22:38:26 +02:00
committed by GitHub
parent 6baa1e4f4a
commit b31f8f0732
6 changed files with 145 additions and 56 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ PODS:
- hermes-engine (0.77.2): - hermes-engine (0.77.2):
- hermes-engine/Pre-built (= 0.77.2) - hermes-engine/Pre-built (= 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 - DoubleConversion
- glog - glog
- hermes-engine - hermes-engine
@@ -1816,7 +1816,7 @@ SPEC CHECKSUMS:
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8 glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8
hermes-engine: 8eb265241fa1d7095d3a40d51fd90f7dce68217c hermes-engine: 8eb265241fa1d7095d3a40d51fd90f7dce68217c
NitroModules: 5cc92d3b0baf1124ed8136a015924eea03db912e NitroModules: 39248e88212416d858a4f4561cf719c6cc8ef900
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
RCTDeprecation: 85b72250b63cfb54f29ca96ceb108cb9ef3c2079 RCTDeprecation: 85b72250b63cfb54f29ca96ceb108cb9ef3c2079
RCTRequired: 567cb8f5d42b990331bfd93faad1d8999b1c1736 RCTRequired: 567cb8f5d42b990331bfd93faad1d8999b1c1736
+18
View File
@@ -136,6 +136,22 @@ const VideoDemo = () => {
[addEvent] [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 // Setup player
const player = useVideoPlayer( const player = useVideoPlayer(
{ {
@@ -157,6 +173,8 @@ const VideoDemo = () => {
useEvent(player, 'onBuffer', handlePlayerBuffer); useEvent(player, 'onBuffer', handlePlayerBuffer);
useEvent(player, 'onProgress', handlePlayerProgress); useEvent(player, 'onProgress', handlePlayerProgress);
useEvent(player, 'onStatusChange', handlePlayerStatusChange); useEvent(player, 'onStatusChange', handlePlayerStatusChange);
useEvent(player, 'onSeek', handlePlayerSeek);
useEvent(player, 'onPlaybackStateChange', handlePlayerStateChange);
// Sync settings with player // Sync settings with player
React.useEffect(() => { React.useEffect(() => {
@@ -11,6 +11,7 @@ import AVFoundation
protocol VideoPlayerObserverDelegate: AnyObject { protocol VideoPlayerObserverDelegate: AnyObject {
func onPlayedToEnd(player: AVPlayer) func onPlayedToEnd(player: AVPlayer)
func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?) func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?)
func onPlayerItemWillChange(hasNewPlayerItem: Bool)
func onTextTrackDataChanged(texts: [NSAttributedString]) func onTextTrackDataChanged(texts: [NSAttributedString])
func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) func onTimedMetadataChanged(timedMetadata: [AVMetadataItem])
func onRateChanged(rate: Float) func onRateChanged(rate: Float)
@@ -28,6 +29,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
extension VideoPlayerObserverDelegate { extension VideoPlayerObserverDelegate {
func onPlayedToEnd(player: AVPlayer) {} func onPlayedToEnd(player: AVPlayer) {}
func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?) {} func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?) {}
func onPlayerItemWillChange(hasNewPlayerItem: Bool) {}
func onTextTrackDataChanged(texts: [NSAttributedString]) {} func onTextTrackDataChanged(texts: [NSAttributedString]) {}
func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) {} func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) {}
func onRateChanged(rate: Float) {} func onRateChanged(rate: Float) {}
@@ -62,6 +64,7 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP
var playbackEndedObserver: NSObjectProtocol? var playbackEndedObserver: NSObjectProtocol?
var playbackBufferEmptyObserver: NSKeyValueObservation? var playbackBufferEmptyObserver: NSKeyValueObservation?
var playbackLikelyToKeepUpObserver: NSKeyValueObservation? var playbackLikelyToKeepUpObserver: NSKeyValueObservation?
var playbackBufferFullObserver: NSKeyValueObservation?
var playerItemStatusObserver: NSKeyValueObservation? var playerItemStatusObserver: NSKeyValueObservation?
var playerItemAccessLogObserver: NSObjectProtocol? var playerItemAccessLogObserver: NSObjectProtocol?
@@ -144,15 +147,7 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP
self?.onPlayerAccessLog(playerItem: playerItem) self?.onPlayerAccessLog(playerItem: playerItem)
} }
playbackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, change in setupBufferObservers(for: playerItem)
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()
}
playerItemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] _, change in playerItemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] _, change in
self?.delegate?.onPlayerItemStatusChanged(status: playerItem.status) self?.delegate?.onPlayerItemStatusChanged(status: playerItem.status)
@@ -178,10 +173,7 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP
self.playerItemAccessLogObserver = nil self.playerItemAccessLogObserver = nil
} }
// Invalidate KVO observers // Invalidate KVO observers
playbackBufferEmptyObserver?.invalidate() clearBufferObservers()
playbackBufferEmptyObserver = nil
playbackLikelyToKeepUpObserver?.invalidate()
playbackLikelyToKeepUpObserver = nil
playerItemStatusObserver?.invalidate() playerItemStatusObserver?.invalidate()
playerItemStatusObserver = nil playerItemStatusObserver = nil
// Remove outputs if needed // Remove outputs if needed
@@ -241,6 +233,9 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP
// Remove observers for old player item // Remove observers for old player item
invalidatePlayerItemObservers() invalidatePlayerItemObservers()
// Notify delegate about player item state change
delegate?.onPlayerItemWillChange(hasNewPlayerItem: newPlayerItem != nil)
if let playerItem = newPlayerItem { if let playerItem = newPlayerItem {
// Initialize observers for new player item // Initialize observers for new player item
initializePlayerItemObservers(player: player, playerItem: playerItem) initializePlayerItemObservers(player: player, playerItem: playerItem)
@@ -256,4 +251,44 @@ class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVP
delegate?.onBandwidthUpdate(bitrate: lastEvent.indicatedBitrate) 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
}
} }
@@ -22,6 +22,7 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate {
func onRateChanged(rate: Float) { func onRateChanged(rate: Float) {
eventEmitter.onPlaybackRateChange(Double(rate)) eventEmitter.onPlaybackRateChange(Double(rate))
updateAndEmitPlaybackState()
} }
func onVolumeChanged(volume: Float) { func onVolumeChanged(volume: Float) {
@@ -29,28 +30,21 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate {
} }
func onPlaybackBufferEmpty() { func onPlaybackBufferEmpty() {
if playerItem?.isPlaybackBufferEmpty == true { isCurrentlyBuffering = true
eventEmitter.onBuffer(true) status = .loading
status = .loading updateAndEmitPlaybackState()
}
} }
func onProgressUpdate(currentTime: Double, bufferDuration bufferDuration: Double) { func onProgressUpdate(currentTime: Double, bufferDuration: Double) {
eventEmitter.onProgress(.init(currentTime: currentTime, bufferDuration: bufferDuration)) eventEmitter.onProgress(.init(currentTime: currentTime, bufferDuration: bufferDuration))
} }
func onPlaybackLikelyToKeepUp() { func onPlaybackLikelyToKeepUp() {
guard let playerItem else { isCurrentlyBuffering = false
return if playerPointer.timeControlStatus == .playing {
}
if !playerItem.isPlaybackBufferEmpty && playerItem.isPlaybackBufferEmpty {
eventEmitter.onBuffer(true)
status = .loading
} else if playerItem.isPlaybackLikelyToKeepUp {
eventEmitter.onBuffer(false)
status = .readytoplay status = .readytoplay
} }
updateAndEmitPlaybackState()
} }
func onExternalPlaybackActiveChanged(isActive: Bool) { func onExternalPlaybackActiveChanged(isActive: Bool) {
@@ -58,62 +52,67 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate {
} }
func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus) { func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus) {
// check for error
if playerPointer.status == .failed || playerItem?.status == .failed { if playerPointer.status == .failed || playerItem?.status == .failed {
self.status = .error self.status = .error
isCurrentlyBuffering = false
eventEmitter.onPlaybackStateChange(.init(isPlaying: false, isBuffering: false)) eventEmitter.onPlaybackStateChange(.init(isPlaying: false, isBuffering: false))
return return
} }
// check if player is waiting to play at specified rate switch status {
if playerPointer.timeControlStatus == .waitingToPlayAtSpecifiedRate { case .waitingToPlayAtSpecifiedRate:
isCurrentlyBuffering = true
self.status = .loading self.status = .loading
return break
}
self.status = .readytoplay
// check for playback state
switch playerPointer.timeControlStatus {
case .playing: case .playing:
eventEmitter.onPlaybackStateChange(.init(isPlaying: true, isBuffering: playerItem?.isPlaybackBufferEmpty == true)) isCurrentlyBuffering = false
self.status = .readytoplay
break
case .paused: case .paused:
eventEmitter.onPlaybackStateChange(.init(isPlaying: false, isBuffering: playerItem?.isPlaybackBufferEmpty == true)) isCurrentlyBuffering = false
default: self.status = .readytoplay
break
@unknown default:
break break
} }
updateAndEmitPlaybackState()
} }
func onPlayerStatusChanged(status: AVPlayer.Status) { func onPlayerStatusChanged(status: AVPlayer.Status) {
// check for error
if status == .failed || playerItem?.status == .failed { if status == .failed || playerItem?.status == .failed {
self.status = .error self.status = .error
isCurrentlyBuffering = false
updateAndEmitPlaybackState()
} }
} }
func onPlayerItemStatusChanged(status: AVPlayerItem.Status) { func onPlayerItemStatusChanged(status: AVPlayerItem.Status) {
if status == .failed { if status == .failed {
self.status = .error self.status = .error
isCurrentlyBuffering = false
updateAndEmitPlaybackState()
return return
} }
switch status { switch status {
case .unknown: case .unknown:
isCurrentlyBuffering = true
self.status = .loading self.status = .loading
case .readyToPlay:
self.status = playerItem?.isPlaybackBufferEmpty == true ? .loading : .readytoplay // Set initial buffering state when we have a playerItem
case .failed: if let playerItem = self.playerItem {
self.status = .error if playerItem.isPlaybackBufferEmpty {
@unknown default: isCurrentlyBuffering = true
break }
}
if self.status == .error || self.status == .readytoplay {
guard let playerItem else {
// unlikely to happen
return
} }
case .readyToPlay:
guard let playerItem else { return }
let height = playerItem.presentationSize.height let height = playerItem.presentationSize.height
let width = playerItem.presentationSize.width let width = playerItem.presentationSize.width
let orientation: VideoOrientation = playerItem.asset.tracks.first(where: { $0.mediaType == .video })?.orientation ?? .unknown let orientation: VideoOrientation = playerItem.asset.tracks.first(where: { $0.mediaType == .video })?.orientation ?? .unknown
@@ -121,7 +120,21 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate {
eventEmitter.onLoad( eventEmitter.onLoad(
.init(currentTime, duration, height, width, orientation) .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]) { func onTextTrackDataChanged(texts: [NSAttributedString]) {
@@ -145,4 +158,23 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate {
func onBandwidthUpdate(bitrate: Double) { func onBandwidthUpdate(bitrate: Double) {
eventEmitter.onBandwidthUpdate(.init(bitrate: bitrate, width: nil, height: nil)) 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)
}
} }
@@ -169,6 +169,9 @@ class HybridVideoPlayer: HybridVideoPlayerSpec {
// Text track selection state // Text track selection state
private var selectedExternalTrackIndex: Int? = nil private var selectedExternalTrackIndex: Int? = nil
// MARK: - Buffering state tracking
var isCurrentlyBuffering: Bool = false
var isPlaying: Bool { var isPlaying: Bool {
get { get {
@@ -185,6 +188,7 @@ class HybridVideoPlayer: HybridVideoPlayerSpec {
func release() { func release() {
playerQueue.async { [weak self] in playerQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.player?.replaceCurrentItem(with: nil) self.player?.replaceCurrentItem(with: nil)
self.player = nil self.player = nil
self.playerItem = nil self.playerItem = nil
@@ -295,7 +299,7 @@ class HybridVideoPlayer: HybridVideoPlayerSpec {
return promise 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. * Initialize the player item synchronously. This is used to initialize the player item before it is set to the player.