fix(ios): notification controls flow (#4854)

* fix(ios): load artwork asynchronously to unblock notification controls

* fix(ios): remove playback observer on player removal and guard artwork callback

* fix: cleaning up player

* refactor: `if` syntax

* fix: add missing cleaner

* fix: remove `rounded` from current time

* chore: update pod versions

* refactor(ios): use targeted update functions at each call site

* refactor(ios): use async/await to load artwork metadata

* fix(ios): update static now playing info after setting external metadata

* fix: find new player before updating playback state

* fix(ios): take over notification controls when registering an already-playing player

* fix(ios): clear stale artwork and guard against item change in async artwork load

* fix: guard notification controls updates against stale player item

* fix: update playback duration periodically to handle streams with initially unknown duration
This commit is contained in:
Kamil Moskała
2026-03-09 17:44:12 +01:00
committed by GitHub
parent ddcb9e6dd9
commit 1b0726cf0e
4 changed files with 83 additions and 55 deletions
+4 -4
View File
@@ -1565,7 +1565,7 @@ PODS:
- React-logger (= 0.77.3)
- React-perflogger (= 0.77.3)
- React-utils (= 0.77.3)
- ReactNativeVideo (7.0.0-beta.1):
- ReactNativeVideo (7.0.0-beta.6):
- DoubleConversion
- glog
- hermes-engine
@@ -1587,7 +1587,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- ReactNativeVideoDrm (7.0.0-beta.1):
- ReactNativeVideoDrm (7.0.0-beta.6):
- DoubleConversion
- glog
- hermes-engine
@@ -1904,8 +1904,8 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 31015410a4a53b9fd0a908ad4d6e3e2b9a25086a
ReactCodegen: 53316394e985ded1babc7f143c90c77d2bb1b43c
ReactCommon: bf4612cba0fa356b529385029f470d5529dddde4
ReactNativeVideo: 10dd0a47f8228b41565a3efb13df5b323633b590
ReactNativeVideoDrm: 07b826ab66fd0a00ab3dd3bef2dd2c026e919a9a
ReactNativeVideo: de7056e831a46412e81fbf730ef739ec4c7378fe
ReactNativeVideoDrm: a83670242cec729b4fb67e7c804ab3300e848b86
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: 92f3bb322c40a86b7233b815854730442e01b8c4
@@ -17,7 +17,6 @@ class NowPlayingInfoCenterManager {
private var skipForwardTarget: Any?
private var skipBackwardTarget: Any?
private var playbackPositionTarget: Any?
private var seekTarget: Any?
private var togglePlayPauseTarget: Any?
private let remoteCommandCenter = MPRemoteCommandCenter.shared()
@@ -47,7 +46,7 @@ class NowPlayingInfoCenterManager {
return
}
if receivingRemoteControlEvents == false {
if !receivingRemoteControlEvents {
receivingRemoteControlEvents = true
}
@@ -58,7 +57,8 @@ class NowPlayingInfoCenterManager {
observers[player.hashValue] = observePlayers(player: player)
players.add(player)
if currentPlayer == nil {
// Also take over if the new player is already playing KVO won't fire since rate hasn't changed
if currentPlayer == nil || player.rate != 0 {
setCurrentPlayer(player: player)
}
}
@@ -75,13 +75,21 @@ class NowPlayingInfoCenterManager {
observers.removeValue(forKey: player.hashValue)
players.remove(player)
if currentPlayer == player {
currentPlayer = nil
updateNowPlayingInfo()
}
if players.allObjects.isEmpty {
cleanup()
return
}
if currentPlayer == player {
if let playbackObserver {
player.removeTimeObserver(playbackObserver)
self.playbackObserver = nil
}
currentPlayer = nil
findNewCurrentPlayer()
if currentPlayer == nil {
updatePlaybackState()
}
}
}
@@ -91,7 +99,9 @@ class NowPlayingInfoCenterManager {
if let playbackObserver {
currentPlayer?.removeTimeObserver(playbackObserver)
self.playbackObserver = nil
}
currentPlayer = nil
invalidateCommandTargets()
@@ -106,6 +116,7 @@ class NowPlayingInfoCenterManager {
if let playbackObserver {
currentPlayer?.removeTimeObserver(playbackObserver)
self.playbackObserver = nil
}
currentPlayer = player
@@ -116,7 +127,7 @@ class NowPlayingInfoCenterManager {
forInterval: CMTime(value: 1, timescale: 4),
queue: .main,
using: { [weak self] _ in
self?.updateNowPlayingInfo()
self?.updatePlaybackState()
}
)
}
@@ -218,13 +229,17 @@ class NowPlayingInfoCenterManager {
}
public func updateNowPlayingInfo() {
guard let player = currentPlayer else {
invalidateCommandTargets()
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
return
}
updateStaticInfo()
updatePlaybackState()
}
guard let currentItem = player.currentItem else {
func updateStaticInfo(ifCurrentItem playerItem: AVPlayerItem) {
guard currentPlayer?.currentItem === playerItem else { return }
updateStaticInfo()
}
func updateStaticInfo() {
guard let player = currentPlayer, let currentItem = player.currentItem else {
return
}
@@ -233,51 +248,63 @@ class NowPlayingInfoCenterManager {
// When the metadata has the tag "iTunSMPB" or "iTunNORM" then the metadata is not converted correctly and comes [nil, nil, ...]
// This leads to a crash of the app
let metadata: [AVMetadataItem] = {
let common = processMetadataItems(currentItem.asset.commonMetadata)
let external = processMetadataItems(currentItem.externalMetadata)
return Array(common.merging(external) { _, new in new }.values)
}()
let titleItem =
AVMetadataItem.metadataItems(
from: metadata,
filteredByIdentifier: .commonIdentifierTitle
).first?.stringValue ?? ""
let title = AVMetadataItem.metadataItems(
from: metadata,
filteredByIdentifier: .commonIdentifierTitle
).first?.stringValue ?? ""
let artistItem =
AVMetadataItem.metadataItems(
from: metadata,
filteredByIdentifier: .commonIdentifierArtist
).first?.stringValue ?? ""
let artist = AVMetadataItem.metadataItems(
from: metadata,
filteredByIdentifier: .commonIdentifierArtist
).first?.stringValue ?? ""
// I have some issue with this - setting artworkItem when it not set dont return nil but also is crashing application
// this is very hacky workaround for it
let imgData = AVMetadataItem.metadataItems(
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
info[MPMediaItemPropertyTitle] = title
info[MPMediaItemPropertyArtist] = artist
info[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
info[MPNowPlayingInfoPropertyIsLiveStream] = CMTIME_IS_INDEFINITE(currentItem.asset.duration)
info[MPMediaItemPropertyArtwork] = nil // Clear artwork from previous item; will be loaded asynchronously below
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// Load artwork asynchronously so notification controls appear immediately.
guard let artworkMetadataItem = AVMetadataItem.metadataItems(
from: metadata,
filteredByIdentifier: .commonIdentifierArtwork
).first?.dataValue
let image = imgData.flatMap { UIImage(data: $0) } ?? UIImage()
let artworkItem = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
).first else { return }
let newNowPlayingInfo: [String: Any] = [
MPMediaItemPropertyTitle: titleItem,
MPMediaItemPropertyArtist: artistItem,
MPMediaItemPropertyArtwork: artworkItem,
MPMediaItemPropertyPlaybackDuration: currentItem.duration.seconds,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentItem.currentTime()
.seconds.rounded(),
MPNowPlayingInfoPropertyPlaybackRate: player.rate,
MPNowPlayingInfoPropertyIsLiveStream: CMTIME_IS_INDEFINITE(
currentItem.asset.duration
),
]
let currentNowPlayingInfo =
MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
Task { [weak self, weak player, weak currentItem] in
guard let data = try? await artworkMetadataItem.load(.dataValue),
let image = UIImage(data: data) else { return }
let artworkItem = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
await MainActor.run {
guard let self, self.currentPlayer === player,
self.currentPlayer?.currentItem === currentItem else { return }
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
info[MPMediaItemPropertyArtwork] = artworkItem
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo =
currentNowPlayingInfo.merging(newNowPlayingInfo) { _, new in new }
func updatePlaybackState() {
guard let player = currentPlayer else {
invalidateCommandTargets()
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
return
}
guard let currentItem = player.currentItem else { return }
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds
info[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
info[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
private func findNewCurrentPlayer() {
@@ -22,7 +22,7 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate {
func onRateChanged(rate: Float) {
_eventEmitter?.onPlaybackRateChange(Double(rate))
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
NowPlayingInfoCenterManager.shared.updatePlaybackState()
updateAndEmitPlaybackState()
}
@@ -408,6 +408,7 @@ class HybridVideoPlayer: HybridVideoPlayerSpec, NativeVideoPlayerSpec {
}
if !items.isEmpty {
playerItem.externalMetadata = items
NowPlayingInfoCenterManager.shared.updateStaticInfo(ifCurrentItem: playerItem)
}
}
@@ -421,7 +422,7 @@ class HybridVideoPlayer: HybridVideoPlayerSpec, NativeVideoPlayerSpec {
DispatchQueue.main.async {
guard let playerItem else { return }
playerItem.externalMetadata = playerItem.externalMetadata + [.make(identifier: .commonIdentifierArtwork, value: data as NSData)]
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
NowPlayingInfoCenterManager.shared.updateStaticInfo(ifCurrentItem: playerItem)
}
}
} else if let imageUri {