From 77cc89479786f6361ce391036c1adacdc121b16a Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 31 Jul 2026 13:29:35 +0200 Subject: [PATCH 1/4] Make nowPlayingInfo access thread safe --- ios/THEOplayerRCTView.swift | 2 +- .../THEOplayerRCTBackgroundAudioManager.swift | 2 +- .../THEOplayerRCTNowPlayingManager.swift | 81 +++++++++++-------- 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/ios/THEOplayerRCTView.swift b/ios/THEOplayerRCTView.swift index 84764ea17..7810a7373 100644 --- a/ios/THEOplayerRCTView.swift +++ b/ios/THEOplayerRCTView.swift @@ -305,7 +305,7 @@ public class THEOplayerRCTView: UIView { } // trigger different feature managers to reflect the current state - self.nowPlayingManager.updateNowPlaying() + self.nowPlayingManager.updateNowPlayingInfo() self.remoteCommandsManager.updateRemoteCommands() self.pipControlsManager.updatePipControls() } diff --git a/ios/backgroundAudio/THEOplayerRCTBackgroundAudioManager.swift b/ios/backgroundAudio/THEOplayerRCTBackgroundAudioManager.swift index 9b510c0cd..e97445f86 100644 --- a/ios/backgroundAudio/THEOplayerRCTBackgroundAudioManager.swift +++ b/ios/backgroundAudio/THEOplayerRCTBackgroundAudioManager.swift @@ -32,7 +32,7 @@ class THEOplayerRCTBackgroundAudioManager: NSObject, BackgroundPlaybackDelegate return false } - view.nowPlayingManager.updateNowPlaying() + view.nowPlayingManager.updateNowPlayingInfo() return view.backgroundAudioConfig.enabled } return false diff --git a/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift b/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift index c681cdcfd..7576fc507 100644 --- a/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift +++ b/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift @@ -7,7 +7,8 @@ import MediaPlayer class THEOplayerRCTNowPlayingManager { // MARK: Members private weak var player: THEOplayer? - private var nowPlayingInfo = [String : Any]() + private let nowPlayingQueue = DispatchQueue(label: "com.theoplayer.reactnative.nowplayinginfo") + private var nowPlayingInfoStorage = [String : Any]() // MARK: player Listeners private var durationChangeListener: EventListener? @@ -25,12 +26,12 @@ class THEOplayerRCTNowPlayingManager { // update elapsed time on close if let player = self.player { updateCurrentTime(player.currentTime) - self.processNowPlayingToInfoCenter() + self.processNowPlayingInfoToInfoCenter() } // clear nowPlayingInfo - self.nowPlayingInfo = [:] - self.clearNowPlayingOnInfoCenter() + self.clearNowPlayingInfoStorage() + self.clearNowPlayingInfoOnInfoCenter() if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] Destroy, nowPlayingInfo cleared on infoCenter.") } } @@ -61,16 +62,15 @@ class THEOplayerRCTNowPlayingManager { } } - func updateNowPlaying() { + func updateNowPlayingInfo() { // Reset any existing playing info - self.nowPlayingInfo = [:] + self.clearNowPlayingInfoStorage() // Gather new playing info if let player = self.player, let sourceDescription = player.source, let metadata = sourceDescription.metadata { let artWorkUrlString = self.getArtWorkUrlStringFromSourceDescription(sourceDescription) - self.nowPlayingInfo = [String : Any]() self.updateTitle(metadata.title) self.updateArtist(metadata.metadataKeys?["artist"] as? String) self.updateAlbum(metadata.metadataKeys?["album"] as? String) @@ -82,15 +82,32 @@ class THEOplayerRCTNowPlayingManager { self.updateContentIdentifier(metadata.metadataKeys?["nowPlayingContentIdentifier"] as? String) self.updateCurrentTime(player.currentTime) self.updateArtWork(artWorkUrlString) { [weak self] in - self?.processNowPlayingToInfoCenter() + self?.processNowPlayingInfoToInfoCenter() } } else { - self.clearNowPlayingOnInfoCenter() + self.clearNowPlayingInfoOnInfoCenter() } } - private func processNowPlayingToInfoCenter() { - let nowPlayingInfo = self.nowPlayingInfo + // MARK: - thread safe nowPlayingInfo access + // nowPlayingInfoStorage is touched both from the main thread (player event listeners, + // updateNowPlayingInfo, destroy) and from the URLSession delegate queue (artwork fetch + // completion). All access is serialized through nowPlayingQueue to avoid corrupting + // the dictionary storage. + private func setNowPlayingInfoStorage(_ key: String, _ value: Any?) { + self.nowPlayingQueue.sync { self.nowPlayingInfoStorage[key] = value } + } + + private func getNowPlayingInfoStorage() -> [String : Any] { + self.nowPlayingQueue.sync { self.nowPlayingInfoStorage } + } + + private func clearNowPlayingInfoStorage() { + self.nowPlayingQueue.sync { self.nowPlayingInfoStorage = [:] } + } + + private func processNowPlayingInfoToInfoCenter() { + let nowPlayingInfo = self.getNowPlayingInfoStorage() if !nowPlayingInfo.isEmpty { Task { @MainActor in MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo @@ -101,11 +118,11 @@ class THEOplayerRCTNowPlayingManager { } } } else { - self.clearNowPlayingOnInfoCenter() + self.clearNowPlayingInfoOnInfoCenter() } } - private func clearNowPlayingOnInfoCenter() { + private func clearNowPlayingInfoOnInfoCenter() { Task { @MainActor in MPNowPlayingInfoCenter.default().nowPlayingInfo = nil if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] clearing nowPlayingInfo (to nil) on infoCenter.") } @@ -130,48 +147,48 @@ class THEOplayerRCTNowPlayingManager { private func updateTitle(_ metadataTitle: String?) { if let title = metadataTitle { - self.nowPlayingInfo[MPMediaItemPropertyTitle] = title + self.setNowPlayingInfoStorage(MPMediaItemPropertyTitle, title) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] title [\(title)] stored in nowPlayingInfo.") } } } private func updateArtist(_ metadataArtist: String?) { if let artist = metadataArtist { - self.nowPlayingInfo[MPMediaItemPropertyArtist] = artist + self.setNowPlayingInfoStorage(MPMediaItemPropertyArtist, artist) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] artist [\(artist)] stored in nowPlayingInfo.") } } } private func updateAlbum(_ metadataAlbum: String?) { if let album = metadataAlbum { - self.nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = album + self.setNowPlayingInfoStorage(MPMediaItemPropertyAlbumTitle, album) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] album [\(album)] stored in nowPlayingInfo.") } } } private func updateSubtitle(_ metadataSubtitle: String?) { if let subtitle = metadataSubtitle { - self.nowPlayingInfo[MPMediaItemPropertyArtist] = subtitle + self.setNowPlayingInfoStorage(MPMediaItemPropertyArtist, subtitle) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] subtitle [\(subtitle)] stored in nowPlayingInfo.") } } } private func updateServiceIdentifier(_ serviceId: String?) { if let serviceId = serviceId { - self.nowPlayingInfo[MPNowPlayingInfoPropertyServiceIdentifier] = serviceId + self.setNowPlayingInfoStorage(MPNowPlayingInfoPropertyServiceIdentifier, serviceId) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] serviceId [\(serviceId)] stored in nowPlayingInfo.") } } } private func updateContentIdentifier(_ contentId: String?) { if let contentId = contentId { - self.nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] = contentId + self.setNowPlayingInfoStorage(MPNowPlayingInfoPropertyExternalContentIdentifier, contentId) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] contentId [\(contentId)] stored in nowPlayingInfo.") } } } private func updateMediaType() { - self.nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = NSNumber(value: 2) + self.setNowPlayingInfoStorage(MPNowPlayingInfoPropertyMediaType, NSNumber(value: 2)) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] mediaType [hardcoded 2, for video] stored in nowPlayingInfo.") } } @@ -181,9 +198,9 @@ class THEOplayerRCTNowPlayingManager { let dataTask = URLSession.shared.dataTask(with: artUrl) { [weak self] (data, _, _) in if let displayIconData = data, let displayIcon = UIImage(data: displayIconData) { - self?.nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: displayIcon.size) { size in + self?.setNowPlayingInfoStorage(MPMediaItemPropertyArtwork, MPMediaItemArtwork(boundsSize: displayIcon.size) { size in return displayIcon - } + }) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] Artwork stored in nowPlayingInfo.") } } else { if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] Failed to store artwork in nowPlayingInfo.") } @@ -197,22 +214,22 @@ class THEOplayerRCTNowPlayingManager { } private func updatePlaybackRate(_ playerPlaybackRate: Double) { - self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = NSNumber(value: playerPlaybackRate) + self.setNowPlayingInfoStorage(MPNowPlayingInfoPropertyPlaybackRate, NSNumber(value: playerPlaybackRate)) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] playbackrate [\(playerPlaybackRate)] stored in nowPlayingInfo.") } } private func updateCurrentTime(_ currentTime: Double) { - self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: currentTime) + self.setNowPlayingInfoStorage(MPNowPlayingInfoPropertyElapsedPlaybackTime, NSNumber(value: currentTime)) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] currentTime [\(currentTime)] stored in nowPlayingInfo.") } } private func updateDuration(_ duration: Double?) { if let duration = duration { let isLiveStream = duration.isInfinite - self.nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = isLiveStream + self.setNowPlayingInfoStorage(MPNowPlayingInfoPropertyIsLiveStream, isLiveStream) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] isLiveStream [\(isLiveStream)] stored in nowPlayingInfo.") } if !isLiveStream { - self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration) + self.setNowPlayingInfoStorage(MPMediaItemPropertyPlaybackDuration, NSNumber(value: duration)) if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] duration [\(duration)] stored in nowPlayingInfo.") } } } @@ -230,7 +247,7 @@ class THEOplayerRCTNowPlayingManager { let wplayer = player, let duration = wplayer.duration { welf.updateDuration(duration) - welf.processNowPlayingToInfoCenter() + welf.processNowPlayingInfoToInfoCenter() } } @@ -241,7 +258,7 @@ class THEOplayerRCTNowPlayingManager { let wplayer = player { welf.updatePlaybackRate(wplayer.playbackRate) welf.updateCurrentTime(wplayer.currentTime) - welf.processNowPlayingToInfoCenter() + welf.processNowPlayingInfoToInfoCenter() } } @@ -252,7 +269,7 @@ class THEOplayerRCTNowPlayingManager { let wplayer = player { welf.updatePlaybackRate(0) welf.updateCurrentTime(wplayer.currentTime) - welf.processNowPlayingToInfoCenter() + welf.processNowPlayingInfoToInfoCenter() } } @@ -264,7 +281,7 @@ class THEOplayerRCTNowPlayingManager { let wplayer = player { welf.updatePlaybackRate(wplayer.playbackRate) welf.updateCurrentTime(wplayer.currentTime) - welf.processNowPlayingToInfoCenter() + welf.processNowPlayingInfoToInfoCenter() } } @@ -275,14 +292,14 @@ class THEOplayerRCTNowPlayingManager { let wplayer = player { welf.updatePlaybackRate(wplayer.playbackRate) welf.updateCurrentTime(wplayer.currentTime) - welf.processNowPlayingToInfoCenter() + welf.processNowPlayingInfoToInfoCenter() } } // SOURCE_CHANGE self.sourceChangeListener = player.addEventListener(type: PlayerEventTypes.SOURCE_CHANGE) { [weak self] event in if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO-EVENT] SOURCE_CHANGE \(event.source == nil ? "to nil" : "")") } - self?.updateNowPlaying() + self?.updateNowPlayingInfo() } } From 806f64f9ff930f4adf26c65d14a5f36def973a2b Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 31 Jul 2026 13:31:44 +0200 Subject: [PATCH 2/4] Add changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5dcc938..39d355473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Fixed an issue on iOS where fetching the artwork for the NowPlayingInfo could cause a crash due to unsafe thread usage. + ## [11.4.1] - 26-07-22 ### Changed From 968ce23cf40bf0e192efa5861b896863019b13e2 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 31 Jul 2026 14:19:55 +0200 Subject: [PATCH 3/4] Add mechanism to prevent stale NowPlayingInfo updates --- .../THEOplayerRCTNowPlayingManager.swift | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift b/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift index 7576fc507..c82e968eb 100644 --- a/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift +++ b/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift @@ -9,6 +9,7 @@ class THEOplayerRCTNowPlayingManager { private weak var player: THEOplayer? private let nowPlayingQueue = DispatchQueue(label: "com.theoplayer.reactnative.nowplayinginfo") private var nowPlayingInfoStorage = [String : Any]() + private var nowPlayingInfoGeneration: Int = 0 // MARK: player Listeners private var durationChangeListener: EventListener? @@ -63,8 +64,8 @@ class THEOplayerRCTNowPlayingManager { } func updateNowPlayingInfo() { - // Reset any existing playing info - self.clearNowPlayingInfoStorage() + // Reset any existing playing info; this invalidates any artwork fetch still in flight + let generation = self.clearNowPlayingInfoStorage() // Gather new playing info if let player = self.player, @@ -81,7 +82,7 @@ class THEOplayerRCTNowPlayingManager { self.updateServiceIdentifier(metadata.metadataKeys?["nowPlayingServiceIdentifier"] as? String) self.updateContentIdentifier(metadata.metadataKeys?["nowPlayingContentIdentifier"] as? String) self.updateCurrentTime(player.currentTime) - self.updateArtWork(artWorkUrlString) { [weak self] in + self.updateArtWork(artWorkUrlString, generation: generation) { [weak self] in self?.processNowPlayingInfoToInfoCenter() } } else { @@ -94,16 +95,41 @@ class THEOplayerRCTNowPlayingManager { // updateNowPlayingInfo, destroy) and from the URLSession delegate queue (artwork fetch // completion). All access is serialized through nowPlayingQueue to avoid corrupting // the dictionary storage. + // + // nowPlayingInfoGeneration identifies the source the storage is currently describing. + // It is bumped on every clear, so an artwork fetch that completes after a source change + // (or after the info was cleared) can detect that it is stale and skip both its write + // and the publication to the info center. private func setNowPlayingInfoStorage(_ key: String, _ value: Any?) { self.nowPlayingQueue.sync { self.nowPlayingInfoStorage[key] = value } } + // Writes only when the generation is still current; returns false when stale. + private func setNowPlayingInfoStorage(_ key: String, _ value: Any?, ifGeneration generation: Int) -> Bool { + self.nowPlayingQueue.sync { + guard generation == self.nowPlayingInfoGeneration else { + return false + } + self.nowPlayingInfoStorage[key] = value + return true + } + } + + private func isCurrentNowPlayingInfoGeneration(_ generation: Int) -> Bool { + self.nowPlayingQueue.sync { generation == self.nowPlayingInfoGeneration } + } + private func getNowPlayingInfoStorage() -> [String : Any] { self.nowPlayingQueue.sync { self.nowPlayingInfoStorage } } - private func clearNowPlayingInfoStorage() { - self.nowPlayingQueue.sync { self.nowPlayingInfoStorage = [:] } + @discardableResult + private func clearNowPlayingInfoStorage() -> Int { + self.nowPlayingQueue.sync { + self.nowPlayingInfoStorage = [:] + self.nowPlayingInfoGeneration += 1 + return self.nowPlayingInfoGeneration + } } private func processNowPlayingInfoToInfoCenter() { @@ -192,17 +218,28 @@ class THEOplayerRCTNowPlayingManager { if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] mediaType [hardcoded 2, for video] stored in nowPlayingInfo.") } } - private func updateArtWork(_ urlString: String?, completion: (() -> Void)?) { + private func updateArtWork(_ urlString: String?, generation: Int, completion: (() -> Void)?) { if let artUrlString = urlString, let artUrl = URL(string: artUrlString) { let dataTask = URLSession.shared.dataTask(with: artUrl) { [weak self] (data, _, _) in + guard let welf = self else { + return + } if let displayIconData = data, let displayIcon = UIImage(data: displayIconData) { - self?.setNowPlayingInfoStorage(MPMediaItemPropertyArtwork, MPMediaItemArtwork(boundsSize: displayIcon.size) { size in + let artWork = MPMediaItemArtwork(boundsSize: displayIcon.size) { size in return displayIcon - }) + } + guard welf.setNowPlayingInfoStorage(MPMediaItemPropertyArtwork, artWork, ifGeneration: generation) else { + if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] Artwork arrived for an outdated source, discarding it.") } + return + } if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] Artwork stored in nowPlayingInfo.") } } else { + guard welf.isCurrentNowPlayingInfoGeneration(generation) else { + if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] Artwork failed for an outdated source, discarding it.") } + return + } if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] Failed to store artwork in nowPlayingInfo.") } } completion?() From a1fdc74b9040a1a26a8d4afb5896f3d9e58e7091 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 31 Jul 2026 14:27:33 +0200 Subject: [PATCH 4/4] Add explicit precedence for subtitle over artist to populate lockscreen's second line --- .../THEOplayerRCTNowPlayingManager.swift | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift b/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift index c82e968eb..5bc59dfcc 100644 --- a/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift +++ b/ios/backgroundAudio/THEOplayerRCTNowPlayingManager.swift @@ -73,9 +73,9 @@ class THEOplayerRCTNowPlayingManager { let metadata = sourceDescription.metadata { let artWorkUrlString = self.getArtWorkUrlStringFromSourceDescription(sourceDescription) self.updateTitle(metadata.title) - self.updateArtist(metadata.metadataKeys?["artist"] as? String) + self.updateSubtitleOrArtist(subtitle: metadata.metadataKeys?["subtitle"] as? String, + artist: metadata.metadataKeys?["artist"] as? String) self.updateAlbum(metadata.metadataKeys?["album"] as? String) - self.updateSubtitle(metadata.metadataKeys?["subtitle"] as? String) self.updateDuration(player.duration) self.updateMediaType() // video self.updatePlaybackRate(player.playbackRate) @@ -178,10 +178,14 @@ class THEOplayerRCTNowPlayingManager { } } - private func updateArtist(_ metadataArtist: String?) { - if let artist = metadataArtist { - self.setNowPlayingInfoStorage(MPMediaItemPropertyArtist, artist) - if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] artist [\(artist)] stored in nowPlayingInfo.") } + // MPMediaItemPropertyArtist holds the secondary line shown below the title. An audio + // stream typically describes it through an artist, while a video stream has no artist but + // can provide a subtitle (e.g. title "My Series" with subtitle "ep.8: My Episode Title"). + // Only one of both can be displayed, so the subtitle takes precedence when both are set. + private func updateSubtitleOrArtist(subtitle metadataSubtitle: String?, artist metadataArtist: String?) { + if let subtitleOrArtist = metadataSubtitle ?? metadataArtist { + self.setNowPlayingInfoStorage(MPMediaItemPropertyArtist, subtitleOrArtist) + if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] subtitle/artist [\(subtitleOrArtist)] stored in nowPlayingInfo.") } } } @@ -192,13 +196,6 @@ class THEOplayerRCTNowPlayingManager { } } - private func updateSubtitle(_ metadataSubtitle: String?) { - if let subtitle = metadataSubtitle { - self.setNowPlayingInfoStorage(MPMediaItemPropertyArtist, subtitle) - if DEBUG_NOWINFO { PrintUtils.printLog(logText: "[NATIVE][NOWPLAYINGINFO] subtitle [\(subtitle)] stored in nowPlayingInfo.") } - } - } - private func updateServiceIdentifier(_ serviceId: String?) { if let serviceId = serviceId { self.setNowPlayingInfoStorage(MPNowPlayingInfoPropertyServiceIdentifier, serviceId)