one 2.11.4 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/android/src/main/java/com/margelo/nitro/one/HybridOneAudio.kt +309 -124
  2. package/dist/cjs/cli/build.cjs +31 -33
  3. package/dist/cjs/cli/build.native.cjs +31 -33
  4. package/dist/cjs/cli/build.native.js +31 -33
  5. package/dist/cjs/cli/build.native.js.map +1 -1
  6. package/dist/cjs/cli/buildPage.cjs +11 -7
  7. package/dist/cjs/cli/buildPage.native.cjs +11 -7
  8. package/dist/cjs/cli/buildPage.native.js +11 -7
  9. package/dist/cjs/cli/buildPage.native.js.map +1 -1
  10. package/dist/cjs/cli/buildPageWorker.cjs +1 -1
  11. package/dist/cjs/cli/buildPageWorker.native.cjs +1 -1
  12. package/dist/cjs/cli/buildPageWorker.native.js +1 -1
  13. package/dist/cjs/cli/buildPageWorker.native.js.map +1 -1
  14. package/dist/cjs/headless-server.cjs +2 -5
  15. package/dist/cjs/headless-server.native.cjs +2 -5
  16. package/dist/cjs/headless-server.native.js +2 -5
  17. package/dist/cjs/headless-server.native.js.map +1 -1
  18. package/dist/cjs/platform/audio/index.cjs +83 -76
  19. package/dist/cjs/platform/audio/index.native.cjs +2 -2
  20. package/dist/cjs/platform/audio/index.native.js +2 -2
  21. package/dist/cjs/platform/audio/index.native.js.map +1 -1
  22. package/dist/cjs/platform/audio/nowPlaying.cjs +126 -0
  23. package/dist/cjs/platform/audio/nowPlaying.native.cjs +160 -0
  24. package/dist/cjs/platform/audio/nowPlaying.native.js +162 -0
  25. package/dist/cjs/platform/audio/nowPlaying.native.js.map +1 -0
  26. package/dist/cjs/platform/audio/player.cjs +15 -0
  27. package/dist/cjs/platform/audio/player.native.cjs +14 -0
  28. package/dist/cjs/platform/audio/player.native.js +14 -0
  29. package/dist/cjs/platform/audio/player.native.js.map +1 -1
  30. package/dist/cjs/platform/audio/unavailable.cjs +1 -1
  31. package/dist/cjs/platform/audio/unavailable.native.cjs +1 -1
  32. package/dist/cjs/platform/audio/unavailable.native.js +1 -1
  33. package/dist/cjs/platform/audio/unavailable.native.js.map +1 -1
  34. package/dist/cjs/platform/location/index.native.cjs +9 -2
  35. package/dist/cjs/platform/location/index.native.js +9 -2
  36. package/dist/cjs/platform/location/index.native.js.map +1 -1
  37. package/dist/cjs/serve-worker.cjs +0 -3
  38. package/dist/cjs/serve-worker.native.cjs +0 -3
  39. package/dist/cjs/serve-worker.native.js +0 -3
  40. package/dist/cjs/serve-worker.native.js.map +1 -1
  41. package/dist/cjs/server/oneServe.cjs +0 -6
  42. package/dist/cjs/server/oneServe.native.cjs +0 -6
  43. package/dist/cjs/server/oneServe.native.js +0 -6
  44. package/dist/cjs/server/oneServe.native.js.map +1 -1
  45. package/dist/cjs/server/workerHandler.cjs +12 -10
  46. package/dist/cjs/server/workerHandler.native.cjs +14 -10
  47. package/dist/cjs/server/workerHandler.native.js +14 -10
  48. package/dist/cjs/server/workerHandler.native.js.map +1 -1
  49. package/dist/cjs/vite/plugins/workerdDevPlugin.cjs +33 -37
  50. package/dist/cjs/vite/plugins/workerdDevPlugin.native.cjs +33 -37
  51. package/dist/cjs/vite/plugins/workerdDevPlugin.native.js +33 -37
  52. package/dist/cjs/vite/plugins/workerdDevPlugin.native.js.map +1 -1
  53. package/dist/esm/cli/build.mjs +31 -33
  54. package/dist/esm/cli/build.mjs.map +1 -1
  55. package/dist/esm/cli/build.native.js +31 -33
  56. package/dist/esm/cli/build.native.js.map +1 -1
  57. package/dist/esm/cli/buildPage.mjs +11 -7
  58. package/dist/esm/cli/buildPage.mjs.map +1 -1
  59. package/dist/esm/cli/buildPage.native.js +11 -7
  60. package/dist/esm/cli/buildPage.native.js.map +1 -1
  61. package/dist/esm/cli/buildPageWorker.mjs +1 -1
  62. package/dist/esm/cli/buildPageWorker.mjs.map +1 -1
  63. package/dist/esm/cli/buildPageWorker.native.js +1 -1
  64. package/dist/esm/cli/buildPageWorker.native.js.map +1 -1
  65. package/dist/esm/headless-server.mjs +2 -3
  66. package/dist/esm/headless-server.mjs.map +1 -1
  67. package/dist/esm/headless-server.native.js +2 -3
  68. package/dist/esm/headless-server.native.js.map +1 -1
  69. package/dist/esm/platform/audio/index.mjs +83 -76
  70. package/dist/esm/platform/audio/index.mjs.map +1 -1
  71. package/dist/esm/platform/audio/index.native.js +2 -2
  72. package/dist/esm/platform/audio/index.native.js.map +1 -1
  73. package/dist/esm/platform/audio/nowPlaying.mjs +102 -0
  74. package/dist/esm/platform/audio/nowPlaying.mjs.map +1 -0
  75. package/dist/esm/platform/audio/nowPlaying.native.js +134 -0
  76. package/dist/esm/platform/audio/nowPlaying.native.js.map +1 -0
  77. package/dist/esm/platform/audio/player.mjs +15 -0
  78. package/dist/esm/platform/audio/player.mjs.map +1 -1
  79. package/dist/esm/platform/audio/player.native.js +14 -0
  80. package/dist/esm/platform/audio/player.native.js.map +1 -1
  81. package/dist/esm/platform/audio/unavailable.mjs +1 -1
  82. package/dist/esm/platform/audio/unavailable.mjs.map +1 -1
  83. package/dist/esm/platform/audio/unavailable.native.js +1 -1
  84. package/dist/esm/platform/audio/unavailable.native.js.map +1 -1
  85. package/dist/esm/platform/location/index.native.js +9 -2
  86. package/dist/esm/platform/location/index.native.js.map +1 -1
  87. package/dist/esm/serve-worker.mjs +1 -2
  88. package/dist/esm/serve-worker.mjs.map +1 -1
  89. package/dist/esm/serve-worker.native.js +1 -2
  90. package/dist/esm/serve-worker.native.js.map +1 -1
  91. package/dist/esm/server/oneServe.mjs +0 -6
  92. package/dist/esm/server/oneServe.mjs.map +1 -1
  93. package/dist/esm/server/oneServe.native.js +0 -6
  94. package/dist/esm/server/oneServe.native.js.map +1 -1
  95. package/dist/esm/server/workerHandler.mjs +12 -10
  96. package/dist/esm/server/workerHandler.mjs.map +1 -1
  97. package/dist/esm/server/workerHandler.native.js +14 -10
  98. package/dist/esm/server/workerHandler.native.js.map +1 -1
  99. package/dist/esm/vite/plugins/workerdDevPlugin.mjs +33 -37
  100. package/dist/esm/vite/plugins/workerdDevPlugin.mjs.map +1 -1
  101. package/dist/esm/vite/plugins/workerdDevPlugin.native.js +33 -37
  102. package/dist/esm/vite/plugins/workerdDevPlugin.native.js.map +1 -1
  103. package/ios/Nitro/HybridOneAudio.swift +309 -96
  104. package/nitrogen/generated/android/c++/JAudioPlayOptions.hpp +61 -0
  105. package/nitrogen/generated/android/c++/JAudioRemoteCommandEvent.hpp +8 -3
  106. package/nitrogen/generated/android/c++/JFunc_void_AudioRemoteCommandEvent.hpp +1 -0
  107. package/nitrogen/generated/android/c++/JHybridOneAudioSpec.cpp +37 -3
  108. package/nitrogen/generated/android/c++/JHybridOneAudioSpec.hpp +3 -1
  109. package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/AudioPlayOptions.kt +56 -0
  110. package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/AudioRemoteCommandEvent.kt +9 -4
  111. package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/HybridOneAudioSpec.kt +40 -32
  112. package/nitrogen/generated/ios/One-Swift-Cxx-Umbrella.hpp +3 -0
  113. package/nitrogen/generated/ios/c++/HybridOneAudioSpecSwift.hpp +21 -2
  114. package/nitrogen/generated/ios/swift/AudioPlayOptions.swift +60 -0
  115. package/nitrogen/generated/ios/swift/AudioRemoteCommandEvent.swift +19 -1
  116. package/nitrogen/generated/ios/swift/HybridOneAudioSpec.swift +3 -1
  117. package/nitrogen/generated/ios/swift/HybridOneAudioSpec_cxx.swift +70 -32
  118. package/nitrogen/generated/shared/c++/AudioPlayOptions.hpp +87 -0
  119. package/nitrogen/generated/shared/c++/AudioRemoteCommandEvent.hpp +7 -2
  120. package/nitrogen/generated/shared/c++/HybridOneAudioSpec.cpp +2 -0
  121. package/nitrogen/generated/shared/c++/HybridOneAudioSpec.hpp +6 -1
  122. package/package.json +16 -16
  123. package/src/cli/build.ts +35 -32
  124. package/src/cli/buildPage.test.ts +152 -0
  125. package/src/cli/buildPage.ts +14 -9
  126. package/src/cli/buildPageWorker.ts +2 -1
  127. package/src/cli/workerPool.ts +1 -0
  128. package/src/headless-server.ts +2 -2
  129. package/src/index.ts +1 -0
  130. package/src/platform/audio/index.native.ts +4 -2
  131. package/src/platform/audio/index.ts +102 -74
  132. package/src/platform/audio/nowPlaying.ts +122 -0
  133. package/src/platform/audio/player.native.ts +17 -1
  134. package/src/platform/audio/player.ts +23 -1
  135. package/src/platform/audio/types.ts +6 -1
  136. package/src/platform/audio/unavailable.ts +3 -1
  137. package/src/platform/index.native.ts +1 -0
  138. package/src/platform/index.ts +1 -0
  139. package/src/platform/location/index.native.ts +14 -3
  140. package/src/platform/specs/OneAudio.nitro.ts +11 -1
  141. package/src/serve-worker.ts +2 -3
  142. package/src/server/oneServe.ts +0 -6
  143. package/src/server/workerHandler.ts +34 -18
  144. package/src/vite/plugins/workerdDevPlugin.ts +33 -37
  145. package/src/vite/resolveResponse.ts +2 -2
  146. package/types/cli/build.d.ts.map +1 -1
  147. package/types/cli/buildPage.d.ts +1 -1
  148. package/types/cli/buildPage.d.ts.map +1 -1
  149. package/types/cli/workerPool.d.ts +1 -0
  150. package/types/cli/workerPool.d.ts.map +1 -1
  151. package/types/headless-server.d.ts +3 -3
  152. package/types/headless-server.d.ts.map +1 -1
  153. package/types/index.d.ts +1 -1
  154. package/types/index.d.ts.map +1 -1
  155. package/types/platform/audio/index.d.ts +2 -2
  156. package/types/platform/audio/index.d.ts.map +1 -1
  157. package/types/platform/audio/index.native.d.ts +3 -3
  158. package/types/platform/audio/index.native.d.ts.map +1 -1
  159. package/types/platform/audio/nowPlaying.d.ts +12 -0
  160. package/types/platform/audio/nowPlaying.d.ts.map +1 -0
  161. package/types/platform/audio/player.d.ts +3 -1
  162. package/types/platform/audio/player.d.ts.map +1 -1
  163. package/types/platform/audio/player.native.d.ts +3 -1
  164. package/types/platform/audio/player.native.d.ts.map +1 -1
  165. package/types/platform/audio/types.d.ts +3 -1
  166. package/types/platform/audio/types.d.ts.map +1 -1
  167. package/types/platform/audio/unavailable.d.ts +3 -3
  168. package/types/platform/audio/unavailable.d.ts.map +1 -1
  169. package/types/platform/index.d.ts +1 -1
  170. package/types/platform/index.d.ts.map +1 -1
  171. package/types/platform/index.native.d.ts +1 -1
  172. package/types/platform/index.native.d.ts.map +1 -1
  173. package/types/platform/location/index.native.d.ts.map +1 -1
  174. package/types/platform/specs/OneAudio.nitro.d.ts +8 -1
  175. package/types/platform/specs/OneAudio.nitro.d.ts.map +1 -1
  176. package/types/serve-worker.d.ts +3 -3
  177. package/types/serve-worker.d.ts.map +1 -1
  178. package/types/server/oneServe.d.ts.map +1 -1
  179. package/types/server/workerHandler.d.ts +1 -1
  180. package/types/server/workerHandler.d.ts.map +1 -1
  181. package/types/vite/plugins/workerdDevPlugin.d.ts.map +1 -1
  182. package/types/vite/resolveResponse.d.ts +1 -1
  183. package/types/vite/resolveResponse.d.ts.map +1 -1
  184. package/dist/cjs/server/staticHtmlFetcher.cjs +0 -33
  185. package/dist/cjs/server/staticHtmlFetcher.native.cjs +0 -35
  186. package/dist/cjs/server/staticHtmlFetcher.native.js +0 -37
  187. package/dist/cjs/server/staticHtmlFetcher.native.js.map +0 -1
  188. package/dist/esm/server/staticHtmlFetcher.mjs +0 -10
  189. package/dist/esm/server/staticHtmlFetcher.mjs.map +0 -1
  190. package/dist/esm/server/staticHtmlFetcher.native.js +0 -10
  191. package/dist/esm/server/staticHtmlFetcher.native.js.map +0 -1
  192. package/src/server/staticHtmlFetcher.ts +0 -12
  193. package/types/server/staticHtmlFetcher.d.ts +0 -3
  194. package/types/server/staticHtmlFetcher.d.ts.map +0 -1
@@ -18,6 +18,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
18
18
  case artwork
19
19
  case unknownPlayer
20
20
  case rate
21
+ case replaced
21
22
 
22
23
  var errorDescription: String? {
23
24
  switch self {
@@ -33,6 +34,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
33
34
  case .artwork: return "now playing artwork must be an existing image file"
34
35
  case .unknownPlayer: return "the audio player id is not active"
35
36
  case .rate: return "the playback rate must be within 0 to 2"
37
+ case .replaced: return "playback was replaced before it was ready"
36
38
  }
37
39
  }
38
40
  }
@@ -51,6 +53,12 @@ final class HybridOneAudio: HybridOneAudioSpec {
51
53
  private var nowPlayingInfo: AudioNowPlayingInfo?
52
54
  private var nowPlayingArtwork: UIImage?
53
55
  private var remoteTargets: [(MPRemoteCommand, Any)] = []
56
+ // one now playing session at a time, owned by the singleton or by one
57
+ // managed player. owners clear only their own claim, so an old owner's
58
+ // cleanup never clears a newer one.
59
+ private var nowPlayingOwner: NowPlayingOwner?
60
+ private var ownerObservations: [NSKeyValueObservation] = []
61
+ private var pendingLoad: PendingLoad?
54
62
  private var remoteCommandListeners: [UUID: (AudioRemoteCommandEvent) -> Void] = [:]
55
63
 
56
64
  private final class ManagedAudioPlayer {
@@ -73,6 +81,24 @@ final class HybridOneAudio: HybridOneAudioSpec {
73
81
 
74
82
  private var managedPlayers: [String: ManagedAudioPlayer] = [:]
75
83
 
84
+ private enum NowPlayingOwner: Equatable {
85
+ case global
86
+ case managed(String)
87
+ }
88
+
89
+ // a singleton play with a position or paused flag: it resolves after the
90
+ // item is ready and the seek lands, before any sound.
91
+ private final class PendingLoad {
92
+ let promise: Promise<AudioPlaybackStatus>
93
+ let positionMs: Double
94
+ var seeking = false
95
+
96
+ init(promise: Promise<AudioPlaybackStatus>, positionMs: Double) {
97
+ self.promise = promise
98
+ self.positionMs = positionMs
99
+ }
100
+ }
101
+
76
102
  override init() {
77
103
  super.init()
78
104
  interruptionObserver = NotificationCenter.default.addObserver(
@@ -121,38 +147,102 @@ final class HybridOneAudio: HybridOneAudioSpec {
121
147
  return promise
122
148
  }
123
149
 
124
- func play(uri: String) throws -> Promise<AudioPlaybackStatus> {
125
- query("play") {
126
- guard self.recorder == nil else { throw Problem.busy }
127
- let url = try Self.audioURL(uri)
128
- if url.isFileURL && !FileManager.default.fileExists(atPath: url.path) {
129
- throw Problem.missingFile
130
- }
131
- let session = AVAudioSession.sharedInstance()
132
- try session.setCategory(.playback, mode: .default)
133
- try session.setActive(true)
134
- self.clearPlayer()
135
- let item = AVPlayerItem(url: url)
136
- let player = AVPlayer(playerItem: item)
137
- self.player = player
138
- self.playerStatusObservation = player.observe(\.timeControlStatus, options: [.new]) {
139
- [weak self] _, _ in
140
- DispatchQueue.main.async { self?.syncNowPlaying() }
141
- }
142
- self.itemStatusObservation = item.observe(\.status, options: [.new]) { [weak self] _, _ in
143
- DispatchQueue.main.async { self?.syncNowPlaying() }
150
+ func play(uri: String, options: AudioPlayOptions) throws -> Promise<AudioPlaybackStatus> {
151
+ let promise = Promise<AudioPlaybackStatus>()
152
+ DispatchQueue.main.async {
153
+ do {
154
+ if let status = try self.startPlayback(uri: uri, options: options, promise: promise) {
155
+ promise.resolve(withResult: status)
156
+ }
157
+ } catch {
158
+ promise.reject(withError: Self.failure("play", error))
144
159
  }
145
- self.playerURI = url.absoluteString
146
- self.playbackPaused = false
147
- self.playbackEnded = false
148
- self.endObserver = NotificationCenter.default.addObserver(
149
- forName: AVPlayerItem.didPlayToEndTimeNotification, object: item, queue: .main
150
- ) { [weak self] _ in
151
- self?.playbackEnded = true
160
+ }
161
+ return promise
162
+ }
163
+
164
+ // returns the status to resolve now, or nil when a prepared load resolves
165
+ // the promise later.
166
+ private func startPlayback(
167
+ uri: String, options: AudioPlayOptions, promise: Promise<AudioPlaybackStatus>
168
+ ) throws -> AudioPlaybackStatus? {
169
+ guard self.recorder == nil else { throw Problem.busy }
170
+ let url = try Self.audioURL(uri)
171
+ if url.isFileURL && !FileManager.default.fileExists(atPath: url.path) {
172
+ throw Problem.missingFile
173
+ }
174
+ let prepare = options.positionMs != nil || options.paused != nil
175
+ let positionMs = options.positionMs ?? 0
176
+ guard positionMs.isFinite && positionMs >= 0 else { throw Problem.position }
177
+ let session = AVAudioSession.sharedInstance()
178
+ try session.setCategory(.playback, mode: .default)
179
+ try session.setActive(true)
180
+ self.clearPlayer()
181
+ let item = AVPlayerItem(url: url)
182
+ let player = AVPlayer(playerItem: item)
183
+ self.player = player
184
+ self.playerStatusObservation = player.observe(\.timeControlStatus, options: [.new]) {
185
+ [weak self] _, _ in
186
+ DispatchQueue.main.async { self?.syncNowPlaying() }
187
+ }
188
+ self.itemStatusObservation = item.observe(\.status, options: [.new]) { [weak self] _, _ in
189
+ DispatchQueue.main.async {
190
+ self?.advanceLoad()
152
191
  self?.syncNowPlaying()
153
192
  }
154
- self.player?.play()
155
- return self.playbackStatus()
193
+ }
194
+ self.playerURI = url.absoluteString
195
+ self.playbackPaused = options.paused ?? false
196
+ self.playbackEnded = false
197
+ self.endObserver = NotificationCenter.default.addObserver(
198
+ forName: AVPlayerItem.didPlayToEndTimeNotification, object: item, queue: .main
199
+ ) { [weak self] _ in
200
+ self?.playbackEnded = true
201
+ self?.syncNowPlaying()
202
+ }
203
+ if prepare {
204
+ self.pendingLoad = PendingLoad(promise: promise, positionMs: positionMs)
205
+ self.advanceLoad()
206
+ return nil
207
+ }
208
+ player.play()
209
+ return self.playbackStatus()
210
+ }
211
+
212
+ // moves a prepared load forward once its item settles: seek exactly to
213
+ // the position, then play unless paused. replacement or stop rejects the
214
+ // load in clearPlayer, so a late completion finds a different load and
215
+ // does nothing.
216
+ private func advanceLoad() {
217
+ guard let load = pendingLoad, !load.seeking, let player, let item = player.currentItem
218
+ else { return }
219
+ switch item.status {
220
+ case .failed:
221
+ pendingLoad = nil
222
+ load.promise.reject(withError: Self.failure("play", item.error ?? Problem.state))
223
+ case .readyToPlay:
224
+ load.seeking = true
225
+ var seconds = load.positionMs / 1000
226
+ let duration = item.duration.seconds
227
+ if duration.isFinite && duration >= 0 { seconds = min(seconds, duration) }
228
+ player.seek(
229
+ to: CMTime(seconds: seconds, preferredTimescale: 600),
230
+ toleranceBefore: .zero, toleranceAfter: .zero
231
+ ) { [weak self] finished in
232
+ DispatchQueue.main.async {
233
+ guard let self, self.pendingLoad === load else { return }
234
+ self.pendingLoad = nil
235
+ guard finished, self.player === player else {
236
+ load.promise.reject(withError: Self.failure("play", Problem.state))
237
+ return
238
+ }
239
+ if !self.playbackPaused { player.play() }
240
+ self.syncNowPlaying()
241
+ load.promise.resolve(withResult: self.playbackStatus())
242
+ }
243
+ }
244
+ default:
245
+ return
156
246
  }
157
247
  }
158
248
 
@@ -178,7 +268,8 @@ final class HybridOneAudio: HybridOneAudioSpec {
178
268
  self.playbackEnded = false
179
269
  }
180
270
  self.playbackPaused = false
181
- player.play()
271
+ // a prepared load plays itself once its seek lands.
272
+ if self.pendingLoad == nil { player.play() }
182
273
  self.syncNowPlaying()
183
274
  return self.playbackStatus()
184
275
  }
@@ -191,7 +282,9 @@ final class HybridOneAudio: HybridOneAudioSpec {
191
282
  promise.reject(withError: Self.failure("seek", Problem.position))
192
283
  return
193
284
  }
194
- guard let player = self.player, player.currentItem?.status == .readyToPlay else {
285
+ guard let player = self.player, player.currentItem?.status == .readyToPlay,
286
+ self.pendingLoad == nil
287
+ else {
195
288
  promise.reject(withError: Self.failure("seek", Problem.state))
196
289
  return
197
290
  }
@@ -311,43 +404,29 @@ final class HybridOneAudio: HybridOneAudioSpec {
311
404
  func setNowPlayingInfo(info: AudioNowPlayingInfo) throws -> Promise<Void> {
312
405
  perform("setNowPlayingInfo") {
313
406
  guard let player = self.player else { throw Problem.state }
314
- guard !info.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
315
- throw Problem.metadata
316
- }
317
- var artwork: UIImage?
318
- if let artworkUri = info.artworkUri {
319
- guard let url = URL(string: artworkUri), url.isFileURL,
320
- FileManager.default.fileExists(atPath: url.path),
321
- let image = UIImage(contentsOfFile: url.path)
322
- else { throw Problem.artwork }
323
- artwork = image
324
- }
325
- if let session = self.nowPlayingSession, session.players.first === player {
326
- self.nowPlayingInfo = info
327
- self.nowPlayingArtwork = artwork
328
- self.syncNowPlaying()
329
- return
330
- }
331
- self.clearNowPlaying()
332
- let session = MPNowPlayingSession(players: [player])
333
- session.automaticallyPublishesNowPlayingInfo = false
334
- self.nowPlayingSession = session
335
- self.nowPlayingInfo = info
336
- self.nowPlayingArtwork = artwork
337
- self.installRemoteCommands(session.remoteCommandCenter)
338
- self.syncNowPlaying()
339
- session.becomeActiveIfPossible { [weak self, weak session] active in
340
- guard active, let session else { return }
341
- DispatchQueue.main.async { [weak self] in
342
- guard let self, self.nowPlayingSession === session else { return }
343
- self.syncNowPlaying()
344
- }
345
- }
407
+ let artwork = try Self.nowPlayingArtwork(info)
408
+ self.claimNowPlaying(.global, player: player, info: info, artwork: artwork)
346
409
  }
347
410
  }
348
411
 
349
412
  func clearNowPlayingInfo() throws -> Promise<Void> {
350
- perform("clearNowPlayingInfo") { self.clearNowPlaying() }
413
+ perform("clearNowPlayingInfo") {
414
+ if self.nowPlayingOwner == .global { self.clearNowPlaying() }
415
+ }
416
+ }
417
+
418
+ func setPlayerNowPlayingInfo(playerId: String, info: AudioNowPlayingInfo) throws -> Promise<Void> {
419
+ perform("setPlayerNowPlayingInfo") {
420
+ let managed = try self.managedPlayer(playerId)
421
+ let artwork = try Self.nowPlayingArtwork(info)
422
+ self.claimNowPlaying(.managed(playerId), player: managed.player, info: info, artwork: artwork)
423
+ }
424
+ }
425
+
426
+ func clearPlayerNowPlayingInfo(playerId: String) throws -> Promise<Void> {
427
+ perform("clearPlayerNowPlayingInfo") {
428
+ if self.nowPlayingOwner == .managed(playerId) { self.clearNowPlaying() }
429
+ }
351
430
  }
352
431
 
353
432
  func addRemoteCommandListener(
@@ -381,16 +460,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
381
460
 
382
461
  func playerPlay(playerId: String) throws -> Void {
383
462
  try mainSync("playerPlay") {
384
- let managed = try self.managedPlayer(playerId)
385
- let session = AVAudioSession.sharedInstance()
386
- try session.setCategory(.playback, mode: .default)
387
- try session.setActive(true)
388
- if managed.ended {
389
- managed.player.seek(to: .zero)
390
- managed.ended = false
391
- }
392
- managed.paused = false
393
- managed.player.rate = Float(managed.rate)
463
+ try self.startManaged(try self.managedPlayer(playerId))
394
464
  }
395
465
  }
396
466
 
@@ -399,6 +469,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
399
469
  let managed = try self.managedPlayer(playerId)
400
470
  managed.player.pause()
401
471
  managed.paused = true
472
+ self.syncOwner(playerId)
402
473
  }
403
474
  }
404
475
 
@@ -426,6 +497,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
426
497
  return
427
498
  }
428
499
  managed.ended = false
500
+ self.syncOwner(playerId)
429
501
  promise.resolve()
430
502
  }
431
503
  }
@@ -450,6 +522,10 @@ final class HybridOneAudio: HybridOneAudioSpec {
450
522
  managed.paused = true
451
523
  managed.ended = false
452
524
  self.installPlayerEndObserver(id: playerId, managed: managed)
525
+ if self.nowPlayingOwner == .managed(playerId) {
526
+ self.observeOwner()
527
+ self.syncNowPlaying()
528
+ }
453
529
  }
454
530
  }
455
531
 
@@ -459,6 +535,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
459
535
  return
460
536
  }
461
537
  guard let managed = managedPlayers.removeValue(forKey: playerId) else { return }
538
+ if nowPlayingOwner == .managed(playerId) { clearNowPlaying() }
462
539
  clearPlayerEndObserver(managed)
463
540
  managed.player.pause()
464
541
  managed.player.replaceCurrentItem(with: nil)
@@ -518,6 +595,22 @@ final class HybridOneAudio: HybridOneAudioSpec {
518
595
  }
519
596
  }
520
597
 
598
+ private func startManaged(_ managed: ManagedAudioPlayer) throws {
599
+ let session = AVAudioSession.sharedInstance()
600
+ try session.setCategory(.playback, mode: .default)
601
+ try session.setActive(true)
602
+ if managed.ended {
603
+ managed.player.seek(to: .zero)
604
+ managed.ended = false
605
+ }
606
+ managed.paused = false
607
+ managed.player.rate = Float(managed.rate)
608
+ }
609
+
610
+ private func syncOwner(_ playerId: String) {
611
+ if nowPlayingOwner == .managed(playerId) { syncNowPlaying() }
612
+ }
613
+
521
614
  private func managedPlayer(_ id: String) throws -> ManagedAudioPlayer {
522
615
  guard let managed = managedPlayers[id] else { throw Problem.unknownPlayer }
523
616
  return managed
@@ -587,6 +680,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
587
680
  }
588
681
  } else {
589
682
  managed.ended = true
683
+ self.syncOwner(id)
590
684
  }
591
685
  }
592
686
  }
@@ -640,6 +734,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
640
734
  let duration = item.duration.seconds
641
735
  let state: AudioPlaybackState
642
736
  if item.status == .failed { state = .failed }
737
+ else if pendingLoad != nil { state = .loading }
643
738
  else if playbackEnded { state = .ended }
644
739
  else if playbackPaused { state = .paused }
645
740
  else if item.status != .readyToPlay || player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
@@ -690,9 +785,9 @@ final class HybridOneAudio: HybridOneAudioSpec {
690
785
 
691
786
  private func handleRemoteToggle() -> MPRemoteCommandHandlerStatus {
692
787
  if !Thread.isMainThread { return DispatchQueue.main.sync { self.handleRemoteToggle() } }
693
- guard let player else { return .noSuchContent }
788
+ guard let player = ownerPlayer() else { return .noSuchContent }
694
789
  return handleRemoteCommand(
695
- playbackPaused || player.timeControlStatus != .playing ? .play : .pause)
790
+ ownerPaused() || player.timeControlStatus != .playing ? .play : .pause)
696
791
  }
697
792
 
698
793
  private func handleRemoteCommand(
@@ -701,33 +796,143 @@ final class HybridOneAudio: HybridOneAudioSpec {
701
796
  if !Thread.isMainThread {
702
797
  return DispatchQueue.main.sync { self.handleRemoteCommand(type, positionMs: positionMs) }
703
798
  }
704
- guard let player, nowPlayingSession != nil else { return .noSuchContent }
705
- switch type {
706
- case .play:
707
- if playbackEnded { player.seek(to: .zero); playbackEnded = false }
708
- playbackPaused = false
709
- player.play()
710
- case .pause:
711
- player.pause()
712
- playbackPaused = true
713
- case .seek:
714
- guard let positionMs, positionMs.isFinite, positionMs >= 0 else {
715
- return .commandFailed
799
+ guard nowPlayingSession != nil, let owner = nowPlayingOwner else { return .noSuchContent }
800
+ if type == .seek {
801
+ guard let positionMs, positionMs.isFinite, positionMs >= 0 else { return .commandFailed }
802
+ }
803
+ var playerId: String?
804
+ switch owner {
805
+ case .global:
806
+ guard let player else { return .noSuchContent }
807
+ switch type {
808
+ case .play:
809
+ if playbackEnded { player.seek(to: .zero); playbackEnded = false }
810
+ playbackPaused = false
811
+ if pendingLoad == nil { player.play() }
812
+ case .pause:
813
+ player.pause()
814
+ playbackPaused = true
815
+ case .seek:
816
+ guard pendingLoad == nil else { return .commandFailed }
817
+ seekOwner(player, positionMs: positionMs!)
818
+ playbackEnded = false
716
819
  }
717
- player.seek(to: CMTime(seconds: positionMs / 1000, preferredTimescale: 600)) {
718
- [weak self] _ in
719
- DispatchQueue.main.async { self?.syncNowPlaying() }
820
+ case .managed(let id):
821
+ guard let managed = managedPlayers[id] else { return .noSuchContent }
822
+ playerId = id
823
+ switch type {
824
+ case .play:
825
+ do { try startManaged(managed) } catch { return .commandFailed }
826
+ case .pause:
827
+ managed.player.pause()
828
+ managed.paused = true
829
+ case .seek:
830
+ guard managed.player.currentItem?.status == .readyToPlay else { return .commandFailed }
831
+ seekOwner(managed.player, positionMs: positionMs!)
832
+ managed.ended = false
720
833
  }
721
- playbackEnded = false
722
834
  }
723
835
  syncNowPlaying()
724
- let event = AudioRemoteCommandEvent(type: type, positionMs: positionMs)
836
+ let event = AudioRemoteCommandEvent(type: type, positionMs: positionMs, playerId: playerId)
725
837
  for listener in Array(remoteCommandListeners.values) { listener(event) }
726
838
  return .success
727
839
  }
728
840
 
841
+ private func seekOwner(_ player: AVPlayer, positionMs: Double) {
842
+ player.seek(to: CMTime(seconds: positionMs / 1000, preferredTimescale: 600)) {
843
+ [weak self] _ in
844
+ DispatchQueue.main.async { self?.syncNowPlaying() }
845
+ }
846
+ }
847
+
848
+ private func ownerPlayer() -> AVPlayer? {
849
+ switch nowPlayingOwner {
850
+ case .global: return player
851
+ case .managed(let id): return managedPlayers[id]?.player
852
+ case nil: return nil
853
+ }
854
+ }
855
+
856
+ private func ownerPaused() -> Bool {
857
+ switch nowPlayingOwner {
858
+ case .global: return playbackPaused
859
+ case .managed(let id): return managedPlayers[id]?.paused ?? true
860
+ case nil: return true
861
+ }
862
+ }
863
+
864
+ private func ownerEnded() -> Bool {
865
+ switch nowPlayingOwner {
866
+ case .global: return playbackEnded
867
+ case .managed(let id): return managedPlayers[id]?.ended ?? false
868
+ case nil: return false
869
+ }
870
+ }
871
+
872
+ private static func nowPlayingArtwork(_ info: AudioNowPlayingInfo) throws -> UIImage? {
873
+ guard !info.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
874
+ throw Problem.metadata
875
+ }
876
+ guard let artworkUri = info.artworkUri else { return nil }
877
+ guard let url = URL(string: artworkUri), url.isFileURL,
878
+ FileManager.default.fileExists(atPath: url.path),
879
+ let image = UIImage(contentsOfFile: url.path)
880
+ else { throw Problem.artwork }
881
+ return image
882
+ }
883
+
884
+ // a claim for the session's own player keeps it. a transfer to another
885
+ // player starts a session for that player and publishes the new card in
886
+ // the same pass, without clearing the old one first. swapping players on
887
+ // one session raised an objective-c exception on the ios 27 simulator.
888
+ private func claimNowPlaying(
889
+ _ owner: NowPlayingOwner, player: AVPlayer, info: AudioNowPlayingInfo, artwork: UIImage?
890
+ ) {
891
+ nowPlayingInfo = info
892
+ nowPlayingArtwork = artwork
893
+ nowPlayingOwner = owner
894
+ let session: MPNowPlayingSession
895
+ if let current = nowPlayingSession, current.players.count == 1,
896
+ current.players[0] === player
897
+ {
898
+ session = current
899
+ } else {
900
+ for (command, target) in remoteTargets { command.removeTarget(target) }
901
+ session = MPNowPlayingSession(players: [player])
902
+ session.automaticallyPublishesNowPlayingInfo = false
903
+ nowPlayingSession = session
904
+ installRemoteCommands(session.remoteCommandCenter)
905
+ }
906
+ observeOwner()
907
+ syncNowPlaying()
908
+ session.becomeActiveIfPossible { [weak self, weak session] active in
909
+ guard active, let session else { return }
910
+ DispatchQueue.main.async { [weak self] in
911
+ guard let self, self.nowPlayingSession === session else { return }
912
+ self.syncNowPlaying()
913
+ }
914
+ }
915
+ }
916
+
917
+ private func observeOwner() {
918
+ ownerObservations.forEach { $0.invalidate() }
919
+ ownerObservations.removeAll()
920
+ guard let player = ownerPlayer() else { return }
921
+ ownerObservations.append(
922
+ player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
923
+ DispatchQueue.main.async { self?.syncNowPlaying() }
924
+ })
925
+ if let item = player.currentItem {
926
+ ownerObservations.append(
927
+ item.observe(\.status, options: [.new]) { [weak self] _, _ in
928
+ DispatchQueue.main.async { self?.syncNowPlaying() }
929
+ })
930
+ }
931
+ }
932
+
729
933
  private func syncNowPlaying() {
730
- guard let session = nowPlayingSession, let info = nowPlayingInfo, let player else { return }
934
+ guard let session = nowPlayingSession, let info = nowPlayingInfo, let player = ownerPlayer()
935
+ else { return }
731
936
  let position = player.currentTime().seconds
732
937
  let duration = player.currentItem?.duration.seconds ?? .nan
733
938
  var metadata: [String: Any] = [
@@ -735,7 +940,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
735
940
  MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
736
941
  MPNowPlayingInfoPropertyElapsedPlaybackTime: position.isFinite ? max(0, position) : 0,
737
942
  MPNowPlayingInfoPropertyPlaybackRate:
738
- !playbackEnded && player.timeControlStatus == .playing ? player.rate : 0.0,
943
+ !ownerEnded() && player.timeControlStatus == .playing ? player.rate : 0.0,
739
944
  MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
740
945
  ]
741
946
  if let artist = info.artist { metadata[MPMediaItemPropertyArtist] = artist }
@@ -756,10 +961,17 @@ final class HybridOneAudio: HybridOneAudioSpec {
756
961
  nowPlayingSession = nil
757
962
  nowPlayingInfo = nil
758
963
  nowPlayingArtwork = nil
964
+ nowPlayingOwner = nil
965
+ ownerObservations.forEach { $0.invalidate() }
966
+ ownerObservations.removeAll()
759
967
  }
760
968
 
761
969
  private func clearPlayer() {
762
- clearNowPlaying()
970
+ if nowPlayingOwner == .global { clearNowPlaying() }
971
+ if let load = pendingLoad {
972
+ pendingLoad = nil
973
+ load.promise.reject(withError: Self.failure("play", Problem.replaced))
974
+ }
763
975
  playerStatusObservation?.invalidate()
764
976
  playerStatusObservation = nil
765
977
  itemStatusObservation?.invalidate()
@@ -818,6 +1030,7 @@ final class HybridOneAudio: HybridOneAudioSpec {
818
1030
  case Problem.artwork: code = "E_AUDIO_ARTWORK"
819
1031
  case Problem.unknownPlayer: code = "E_AUDIO_STATE"
820
1032
  case Problem.rate: code = "E_AUDIO_RATE"
1033
+ case Problem.replaced: code = "E_AUDIO_STATE"
821
1034
  default: code = "E_AUDIO_FAILED"
822
1035
  }
823
1036
  return oneNativeError(code, "Audio.\(verb): \(error.localizedDescription)")
@@ -0,0 +1,61 @@
1
+ ///
2
+ /// JAudioPlayOptions.hpp
3
+ /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
4
+ /// https://github.com/mrousavy/nitro
5
+ /// Copyright © Marc Rousavy @ Margelo
6
+ ///
7
+
8
+ #pragma once
9
+
10
+ #include <fbjni/fbjni.h>
11
+ #include "AudioPlayOptions.hpp"
12
+
13
+ #include <optional>
14
+
15
+ namespace margelo::nitro::one {
16
+
17
+ using namespace facebook;
18
+
19
+ /**
20
+ * The C++ JNI bridge between the C++ struct "AudioPlayOptions" and the Kotlin data class "AudioPlayOptions".
21
+ */
22
+ struct JAudioPlayOptions final: public jni::JavaClass<JAudioPlayOptions> {
23
+ public:
24
+ static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/one/AudioPlayOptions;";
25
+
26
+ public:
27
+ /**
28
+ * Convert this Java/Kotlin-based struct to the C++ struct AudioPlayOptions by copying all values to C++.
29
+ */
30
+ [[maybe_unused]]
31
+ [[nodiscard]]
32
+ AudioPlayOptions toCpp() const {
33
+ static const auto clazz = javaClassStatic();
34
+ static const auto fieldPositionMs = clazz->getField<jni::JDouble>("positionMs");
35
+ jni::local_ref<jni::JDouble> positionMs = this->getFieldValue(fieldPositionMs);
36
+ static const auto fieldPaused = clazz->getField<jni::JBoolean>("paused");
37
+ jni::local_ref<jni::JBoolean> paused = this->getFieldValue(fieldPaused);
38
+ return AudioPlayOptions(
39
+ positionMs != nullptr ? std::make_optional(positionMs->value()) : std::nullopt,
40
+ paused != nullptr ? std::make_optional(static_cast<bool>(paused->value())) : std::nullopt
41
+ );
42
+ }
43
+
44
+ public:
45
+ /**
46
+ * Create a Java/Kotlin-based struct by copying all values from the given C++ struct to Java.
47
+ */
48
+ [[maybe_unused]]
49
+ static jni::local_ref<JAudioPlayOptions::javaobject> fromCpp(const AudioPlayOptions& value) {
50
+ using JSignature = JAudioPlayOptions(jni::alias_ref<jni::JDouble>, jni::alias_ref<jni::JBoolean>);
51
+ static const auto clazz = javaClassStatic();
52
+ static const auto create = clazz->getStaticMethod<JSignature>("fromCpp");
53
+ return create(
54
+ clazz,
55
+ value.positionMs.has_value() ? jni::JDouble::valueOf(value.positionMs.value()) : nullptr,
56
+ value.paused.has_value() ? jni::JBoolean::valueOf(value.paused.value()) : nullptr
57
+ );
58
+ }
59
+ };
60
+
61
+ } // namespace margelo::nitro::one
@@ -13,6 +13,7 @@
13
13
  #include "AudioRemoteCommandType.hpp"
14
14
  #include "JAudioRemoteCommandType.hpp"
15
15
  #include <optional>
16
+ #include <string>
16
17
 
17
18
  namespace margelo::nitro::one {
18
19
 
@@ -37,9 +38,12 @@ namespace margelo::nitro::one {
37
38
  jni::local_ref<JAudioRemoteCommandType> type = this->getFieldValue(fieldType);
38
39
  static const auto fieldPositionMs = clazz->getField<jni::JDouble>("positionMs");
39
40
  jni::local_ref<jni::JDouble> positionMs = this->getFieldValue(fieldPositionMs);
41
+ static const auto fieldPlayerId = clazz->getField<jni::JString>("playerId");
42
+ jni::local_ref<jni::JString> playerId = this->getFieldValue(fieldPlayerId);
40
43
  return AudioRemoteCommandEvent(
41
44
  type->toCpp(),
42
- positionMs != nullptr ? std::make_optional(positionMs->value()) : std::nullopt
45
+ positionMs != nullptr ? std::make_optional(positionMs->value()) : std::nullopt,
46
+ playerId != nullptr ? std::make_optional(playerId->toStdString()) : std::nullopt
43
47
  );
44
48
  }
45
49
 
@@ -49,13 +53,14 @@ namespace margelo::nitro::one {
49
53
  */
50
54
  [[maybe_unused]]
51
55
  static jni::local_ref<JAudioRemoteCommandEvent::javaobject> fromCpp(const AudioRemoteCommandEvent& value) {
52
- using JSignature = JAudioRemoteCommandEvent(jni::alias_ref<JAudioRemoteCommandType>, jni::alias_ref<jni::JDouble>);
56
+ using JSignature = JAudioRemoteCommandEvent(jni::alias_ref<JAudioRemoteCommandType>, jni::alias_ref<jni::JDouble>, jni::alias_ref<jni::JString>);
53
57
  static const auto clazz = javaClassStatic();
54
58
  static const auto create = clazz->getStaticMethod<JSignature>("fromCpp");
55
59
  return create(
56
60
  clazz,
57
61
  JAudioRemoteCommandType::fromCpp(value.type),
58
- value.positionMs.has_value() ? jni::JDouble::valueOf(value.positionMs.value()) : nullptr
62
+ value.positionMs.has_value() ? jni::JDouble::valueOf(value.positionMs.value()) : nullptr,
63
+ value.playerId.has_value() ? jni::make_jstring(value.playerId.value()) : nullptr
59
64
  );
60
65
  }
61
66
  };
@@ -17,6 +17,7 @@
17
17
  #include "AudioRemoteCommandType.hpp"
18
18
  #include "JAudioRemoteCommandType.hpp"
19
19
  #include <optional>
20
+ #include <string>
20
21
 
21
22
  namespace margelo::nitro::one {
22
23