one 2.11.5 → 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 (187) 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/serve-worker.cjs +0 -3
  35. package/dist/cjs/serve-worker.native.cjs +0 -3
  36. package/dist/cjs/serve-worker.native.js +0 -3
  37. package/dist/cjs/serve-worker.native.js.map +1 -1
  38. package/dist/cjs/server/oneServe.cjs +0 -6
  39. package/dist/cjs/server/oneServe.native.cjs +0 -6
  40. package/dist/cjs/server/oneServe.native.js +0 -6
  41. package/dist/cjs/server/oneServe.native.js.map +1 -1
  42. package/dist/cjs/server/workerHandler.cjs +12 -10
  43. package/dist/cjs/server/workerHandler.native.cjs +14 -10
  44. package/dist/cjs/server/workerHandler.native.js +14 -10
  45. package/dist/cjs/server/workerHandler.native.js.map +1 -1
  46. package/dist/cjs/vite/plugins/workerdDevPlugin.cjs +33 -37
  47. package/dist/cjs/vite/plugins/workerdDevPlugin.native.cjs +33 -37
  48. package/dist/cjs/vite/plugins/workerdDevPlugin.native.js +33 -37
  49. package/dist/cjs/vite/plugins/workerdDevPlugin.native.js.map +1 -1
  50. package/dist/esm/cli/build.mjs +31 -33
  51. package/dist/esm/cli/build.mjs.map +1 -1
  52. package/dist/esm/cli/build.native.js +31 -33
  53. package/dist/esm/cli/build.native.js.map +1 -1
  54. package/dist/esm/cli/buildPage.mjs +11 -7
  55. package/dist/esm/cli/buildPage.mjs.map +1 -1
  56. package/dist/esm/cli/buildPage.native.js +11 -7
  57. package/dist/esm/cli/buildPage.native.js.map +1 -1
  58. package/dist/esm/cli/buildPageWorker.mjs +1 -1
  59. package/dist/esm/cli/buildPageWorker.mjs.map +1 -1
  60. package/dist/esm/cli/buildPageWorker.native.js +1 -1
  61. package/dist/esm/cli/buildPageWorker.native.js.map +1 -1
  62. package/dist/esm/headless-server.mjs +2 -3
  63. package/dist/esm/headless-server.mjs.map +1 -1
  64. package/dist/esm/headless-server.native.js +2 -3
  65. package/dist/esm/headless-server.native.js.map +1 -1
  66. package/dist/esm/platform/audio/index.mjs +83 -76
  67. package/dist/esm/platform/audio/index.mjs.map +1 -1
  68. package/dist/esm/platform/audio/index.native.js +2 -2
  69. package/dist/esm/platform/audio/index.native.js.map +1 -1
  70. package/dist/esm/platform/audio/nowPlaying.mjs +102 -0
  71. package/dist/esm/platform/audio/nowPlaying.mjs.map +1 -0
  72. package/dist/esm/platform/audio/nowPlaying.native.js +134 -0
  73. package/dist/esm/platform/audio/nowPlaying.native.js.map +1 -0
  74. package/dist/esm/platform/audio/player.mjs +15 -0
  75. package/dist/esm/platform/audio/player.mjs.map +1 -1
  76. package/dist/esm/platform/audio/player.native.js +14 -0
  77. package/dist/esm/platform/audio/player.native.js.map +1 -1
  78. package/dist/esm/platform/audio/unavailable.mjs +1 -1
  79. package/dist/esm/platform/audio/unavailable.mjs.map +1 -1
  80. package/dist/esm/platform/audio/unavailable.native.js +1 -1
  81. package/dist/esm/platform/audio/unavailable.native.js.map +1 -1
  82. package/dist/esm/serve-worker.mjs +1 -2
  83. package/dist/esm/serve-worker.mjs.map +1 -1
  84. package/dist/esm/serve-worker.native.js +1 -2
  85. package/dist/esm/serve-worker.native.js.map +1 -1
  86. package/dist/esm/server/oneServe.mjs +0 -6
  87. package/dist/esm/server/oneServe.mjs.map +1 -1
  88. package/dist/esm/server/oneServe.native.js +0 -6
  89. package/dist/esm/server/oneServe.native.js.map +1 -1
  90. package/dist/esm/server/workerHandler.mjs +12 -10
  91. package/dist/esm/server/workerHandler.mjs.map +1 -1
  92. package/dist/esm/server/workerHandler.native.js +14 -10
  93. package/dist/esm/server/workerHandler.native.js.map +1 -1
  94. package/dist/esm/vite/plugins/workerdDevPlugin.mjs +33 -37
  95. package/dist/esm/vite/plugins/workerdDevPlugin.mjs.map +1 -1
  96. package/dist/esm/vite/plugins/workerdDevPlugin.native.js +33 -37
  97. package/dist/esm/vite/plugins/workerdDevPlugin.native.js.map +1 -1
  98. package/ios/Nitro/HybridOneAudio.swift +309 -96
  99. package/nitrogen/generated/android/c++/JAudioPlayOptions.hpp +61 -0
  100. package/nitrogen/generated/android/c++/JAudioRemoteCommandEvent.hpp +8 -3
  101. package/nitrogen/generated/android/c++/JFunc_void_AudioRemoteCommandEvent.hpp +1 -0
  102. package/nitrogen/generated/android/c++/JHybridOneAudioSpec.cpp +37 -3
  103. package/nitrogen/generated/android/c++/JHybridOneAudioSpec.hpp +3 -1
  104. package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/AudioPlayOptions.kt +56 -0
  105. package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/AudioRemoteCommandEvent.kt +9 -4
  106. package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/HybridOneAudioSpec.kt +40 -32
  107. package/nitrogen/generated/ios/One-Swift-Cxx-Umbrella.hpp +3 -0
  108. package/nitrogen/generated/ios/c++/HybridOneAudioSpecSwift.hpp +21 -2
  109. package/nitrogen/generated/ios/swift/AudioPlayOptions.swift +60 -0
  110. package/nitrogen/generated/ios/swift/AudioRemoteCommandEvent.swift +19 -1
  111. package/nitrogen/generated/ios/swift/HybridOneAudioSpec.swift +3 -1
  112. package/nitrogen/generated/ios/swift/HybridOneAudioSpec_cxx.swift +70 -32
  113. package/nitrogen/generated/shared/c++/AudioPlayOptions.hpp +87 -0
  114. package/nitrogen/generated/shared/c++/AudioRemoteCommandEvent.hpp +7 -2
  115. package/nitrogen/generated/shared/c++/HybridOneAudioSpec.cpp +2 -0
  116. package/nitrogen/generated/shared/c++/HybridOneAudioSpec.hpp +6 -1
  117. package/package.json +13 -13
  118. package/src/cli/build.ts +35 -32
  119. package/src/cli/buildPage.test.ts +152 -0
  120. package/src/cli/buildPage.ts +14 -9
  121. package/src/cli/buildPageWorker.ts +2 -1
  122. package/src/cli/workerPool.ts +1 -0
  123. package/src/headless-server.ts +2 -2
  124. package/src/index.ts +1 -0
  125. package/src/platform/audio/index.native.ts +4 -2
  126. package/src/platform/audio/index.ts +102 -74
  127. package/src/platform/audio/nowPlaying.ts +122 -0
  128. package/src/platform/audio/player.native.ts +17 -1
  129. package/src/platform/audio/player.ts +23 -1
  130. package/src/platform/audio/types.ts +6 -1
  131. package/src/platform/audio/unavailable.ts +3 -1
  132. package/src/platform/index.native.ts +1 -0
  133. package/src/platform/index.ts +1 -0
  134. package/src/platform/specs/OneAudio.nitro.ts +11 -1
  135. package/src/serve-worker.ts +2 -3
  136. package/src/server/oneServe.ts +0 -6
  137. package/src/server/workerHandler.ts +34 -18
  138. package/src/vite/plugins/workerdDevPlugin.ts +33 -37
  139. package/src/vite/resolveResponse.ts +2 -2
  140. package/types/cli/build.d.ts.map +1 -1
  141. package/types/cli/buildPage.d.ts +1 -1
  142. package/types/cli/buildPage.d.ts.map +1 -1
  143. package/types/cli/workerPool.d.ts +1 -0
  144. package/types/cli/workerPool.d.ts.map +1 -1
  145. package/types/headless-server.d.ts +3 -3
  146. package/types/headless-server.d.ts.map +1 -1
  147. package/types/index.d.ts +1 -1
  148. package/types/index.d.ts.map +1 -1
  149. package/types/platform/audio/index.d.ts +2 -2
  150. package/types/platform/audio/index.d.ts.map +1 -1
  151. package/types/platform/audio/index.native.d.ts +3 -3
  152. package/types/platform/audio/index.native.d.ts.map +1 -1
  153. package/types/platform/audio/nowPlaying.d.ts +12 -0
  154. package/types/platform/audio/nowPlaying.d.ts.map +1 -0
  155. package/types/platform/audio/player.d.ts +3 -1
  156. package/types/platform/audio/player.d.ts.map +1 -1
  157. package/types/platform/audio/player.native.d.ts +3 -1
  158. package/types/platform/audio/player.native.d.ts.map +1 -1
  159. package/types/platform/audio/types.d.ts +3 -1
  160. package/types/platform/audio/types.d.ts.map +1 -1
  161. package/types/platform/audio/unavailable.d.ts +3 -3
  162. package/types/platform/audio/unavailable.d.ts.map +1 -1
  163. package/types/platform/index.d.ts +1 -1
  164. package/types/platform/index.d.ts.map +1 -1
  165. package/types/platform/index.native.d.ts +1 -1
  166. package/types/platform/index.native.d.ts.map +1 -1
  167. package/types/platform/specs/OneAudio.nitro.d.ts +8 -1
  168. package/types/platform/specs/OneAudio.nitro.d.ts.map +1 -1
  169. package/types/serve-worker.d.ts +3 -3
  170. package/types/serve-worker.d.ts.map +1 -1
  171. package/types/server/oneServe.d.ts.map +1 -1
  172. package/types/server/workerHandler.d.ts +1 -1
  173. package/types/server/workerHandler.d.ts.map +1 -1
  174. package/types/vite/plugins/workerdDevPlugin.d.ts.map +1 -1
  175. package/types/vite/resolveResponse.d.ts +1 -1
  176. package/types/vite/resolveResponse.d.ts.map +1 -1
  177. package/dist/cjs/server/staticHtmlFetcher.cjs +0 -33
  178. package/dist/cjs/server/staticHtmlFetcher.native.cjs +0 -35
  179. package/dist/cjs/server/staticHtmlFetcher.native.js +0 -37
  180. package/dist/cjs/server/staticHtmlFetcher.native.js.map +0 -1
  181. package/dist/esm/server/staticHtmlFetcher.mjs +0 -10
  182. package/dist/esm/server/staticHtmlFetcher.mjs.map +0 -1
  183. package/dist/esm/server/staticHtmlFetcher.native.js +0 -10
  184. package/dist/esm/server/staticHtmlFetcher.native.js.map +0 -1
  185. package/src/server/staticHtmlFetcher.ts +0 -12
  186. package/types/server/staticHtmlFetcher.d.ts +0 -3
  187. package/types/server/staticHtmlFetcher.d.ts.map +0 -1
@@ -64,6 +64,12 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
64
64
  private var mediaSession: MediaSession? = null
65
65
  private var nowPlayingInfo: AudioNowPlayingInfo? = null
66
66
  private var nowPlayingArtworkPath: String? = null
67
+ // one media session at a time, owned by the singleton or by one managed
68
+ // player. owners clear only their own claim, so an old owner's cleanup
69
+ // never clears a newer one.
70
+ private var nowPlayingOwner: NowPlayingOwner? = null
71
+ private var pendingLoad: PendingLoad? = null
72
+ private var serviceRunning = false
67
73
  private val interruptionListeners = mutableMapOf<String, (AudioInterruptionEvent) -> Unit>()
68
74
  private val remoteCommandListeners = mutableMapOf<String, (AudioRemoteCommandEvent) -> Unit>()
69
75
 
@@ -85,6 +91,7 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
85
91
  permission?.reject(OneNativeError(E_PERMISSION, "Audio.requestRecordingPermission: torn down mid-request"))
86
92
  main.post {
87
93
  clearPlayer()
94
+ clearNowPlaying()
88
95
  val managed = managedPlayers.values.toList()
89
96
  managedPlayers.clear()
90
97
  managed.forEach { releaseManagedPlayer(it) }
@@ -155,9 +162,11 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
155
162
  return true
156
163
  }
157
164
 
158
- override fun play(uri: String): Promise<AudioPlaybackStatus> {
165
+ override fun play(uri: String, options: AudioPlayOptions): Promise<AudioPlaybackStatus> {
159
166
  val promise = Promise<AudioPlaybackStatus>()
160
167
  main.post {
168
+ val prepare = options.positionMs != null || options.paused != null
169
+ val positionMs = options.positionMs ?: 0.0
161
170
  if (recorder != null) {
162
171
  promise.reject(OneNativeError(E_BUSY, "Audio.play: stop the current audio operation first"))
163
172
  return@post
@@ -178,6 +187,10 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
178
187
  promise.reject(OneNativeError(E_FILE, "Audio.play: the audio file does not exist"))
179
188
  return@post
180
189
  }
190
+ if (!positionMs.isFinite() || positionMs < 0) {
191
+ promise.reject(OneNativeError(E_POSITION, "Audio.play: the seek position must be finite and nonnegative"))
192
+ return@post
193
+ }
181
194
  try {
182
195
  requestFocus()
183
196
  clearPlayer()
@@ -196,28 +209,43 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
196
209
  player = fresh
197
210
  playerUri = parsed.toString()
198
211
  playerPrepared = false
199
- playbackPaused = false
212
+ playbackPaused = options.paused ?: false
200
213
  playbackEnded = false
201
214
  playbackError = null
202
215
  fresh.setOnPreparedListener { mp ->
216
+ if (player !== mp) return@setOnPreparedListener
203
217
  playerPrepared = true
204
- mp.start()
205
- startServiceIfBackground()
218
+ val load = pendingLoad
219
+ if (load != null) {
220
+ seekLoad(mp, load)
221
+ return@setOnPreparedListener
222
+ }
223
+ // a pause while loading holds; resume starts it later.
224
+ if (!playbackPaused) mp.start()
225
+ syncService()
206
226
  syncNowPlaying()
207
227
  }
208
228
  fresh.setOnCompletionListener {
209
229
  playbackEnded = true
210
- stopService()
230
+ syncService()
211
231
  syncNowPlaying()
212
232
  }
213
233
  fresh.setOnErrorListener { _, what, extra ->
214
234
  playbackError = "playback failed ($what/$extra)"
215
- stopService()
235
+ pendingLoad?.let { load ->
236
+ pendingLoad = null
237
+ load.promise.reject(OneNativeError(E_FAILED, "Audio.play: $playbackError"))
238
+ }
239
+ syncService()
216
240
  syncNowPlaying()
217
241
  true
218
242
  }
219
243
  fresh.prepareAsync()
220
- promise.resolve(playbackStatus())
244
+ if (prepare) {
245
+ pendingLoad = PendingLoad(promise, positionMs)
246
+ } else {
247
+ promise.resolve(playbackStatus())
248
+ }
221
249
  } catch (e: Exception) {
222
250
  clearPlayer()
223
251
  abandonFocus()
@@ -246,7 +274,7 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
246
274
  } catch (e: Exception) {
247
275
  }
248
276
  playbackPaused = true
249
- stopService()
277
+ syncService()
250
278
  syncNowPlaying()
251
279
  promise.resolve(playbackStatus())
252
280
  }
@@ -267,8 +295,9 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
267
295
  playbackEnded = false
268
296
  }
269
297
  playbackPaused = false
270
- if (playerPrepared) current.start()
271
- startServiceIfBackground()
298
+ // a prepared load starts itself once its seek lands.
299
+ if (playerPrepared && pendingLoad == null) current.start()
300
+ syncService()
272
301
  syncNowPlaying()
273
302
  promise.resolve(playbackStatus())
274
303
  } catch (e: Exception) {
@@ -286,7 +315,7 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
286
315
  return@post
287
316
  }
288
317
  val current = player
289
- if (current == null || !playerPrepared) {
318
+ if (current == null || !playerPrepared || pendingLoad != null) {
290
319
  promise.reject(OneNativeError(E_STATE, "Audio.seek: the audio operation is not ready"))
291
320
  return@post
292
321
  }
@@ -496,65 +525,49 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
496
525
  override fun setNowPlayingInfo(info: AudioNowPlayingInfo): Promise<Unit> {
497
526
  val promise = Promise<Unit>()
498
527
  main.post {
499
- val current = player
500
- if (current == null) {
528
+ if (player == null) {
501
529
  promise.reject(OneNativeError(E_STATE, "Audio.setNowPlayingInfo: the audio operation is not ready"))
502
530
  return@post
503
531
  }
504
- if (info.title.trim().isEmpty()) {
505
- promise.reject(OneNativeError(E_METADATA, "Audio.setNowPlayingInfo: now playing title must not be empty"))
506
- return@post
507
- }
508
- var artworkPath: String? = null
509
- info.artworkUri?.let { artworkUri ->
510
- val parsed = try {
511
- Uri.parse(artworkUri)
512
- } catch (e: Exception) {
513
- null
514
- }
515
- val file = if (parsed?.scheme == "file") parsed.path?.let { File(it) } else null
516
- if (file == null || !file.exists() || file.isDirectory ||
517
- BitmapFactory.decodeFile(file.absolutePath) == null
518
- ) {
519
- promise.reject(
520
- OneNativeError(E_ARTWORK, "Audio.setNowPlayingInfo: now playing artwork must be an existing image file")
521
- )
522
- return@post
523
- }
524
- artworkPath = file.absolutePath
532
+ try {
533
+ val artworkPath = nowPlayingArtwork("setNowPlayingInfo", info)
534
+ claimNowPlaying(NowPlayingOwner.Global, info, artworkPath)
535
+ promise.resolve(Unit)
536
+ } catch (e: OneNativeError) {
537
+ promise.reject(e)
525
538
  }
526
- if (mediaSession == null) {
527
- val session = MediaSession(context, "OneAudio")
528
- session.setCallback(
529
- object : MediaSession.Callback() {
530
- override fun onPlay() {
531
- handleRemoteCommand(AudioRemoteCommandType.PLAY, null)
532
- }
539
+ }
540
+ return promise
541
+ }
533
542
 
534
- override fun onPause() {
535
- handleRemoteCommand(AudioRemoteCommandType.PAUSE, null)
536
- }
543
+ override fun clearNowPlayingInfo(): Promise<Unit> {
544
+ val promise = Promise<Unit>()
545
+ main.post {
546
+ if (nowPlayingOwner == NowPlayingOwner.Global) clearNowPlaying()
547
+ promise.resolve(Unit)
548
+ }
549
+ return promise
550
+ }
537
551
 
538
- override fun onSeekTo(pos: Long) {
539
- handleRemoteCommand(AudioRemoteCommandType.SEEK, pos.toDouble())
540
- }
541
- }
542
- )
543
- mediaSession = session
552
+ override fun setPlayerNowPlayingInfo(playerId: String, info: AudioNowPlayingInfo): Promise<Unit> {
553
+ val promise = Promise<Unit>()
554
+ main.post {
555
+ try {
556
+ managedPlayer(playerId)
557
+ val artworkPath = nowPlayingArtwork("setPlayerNowPlayingInfo", info)
558
+ claimNowPlaying(NowPlayingOwner.Managed(playerId), info, artworkPath)
559
+ promise.resolve(Unit)
560
+ } catch (e: OneNativeError) {
561
+ promise.reject(e)
544
562
  }
545
- nowPlayingInfo = info
546
- nowPlayingArtworkPath = artworkPath
547
- mediaSession?.isActive = true
548
- syncNowPlaying()
549
- promise.resolve(Unit)
550
563
  }
551
564
  return promise
552
565
  }
553
566
 
554
- override fun clearNowPlayingInfo(): Promise<Unit> {
567
+ override fun clearPlayerNowPlayingInfo(playerId: String): Promise<Unit> {
555
568
  val promise = Promise<Unit>()
556
569
  main.post {
557
- clearNowPlaying()
570
+ if (nowPlayingOwner == NowPlayingOwner.Managed(playerId)) clearNowPlaying()
558
571
  promise.resolve(Unit)
559
572
  }
560
573
  return promise
@@ -582,32 +595,12 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
582
595
  }
583
596
 
584
597
  override fun playerPlay(playerId: String) {
585
- mainSync("playerPlay") {
586
- val managed = managedPlayer(playerId)
587
- requestFocus()
588
- val current = managed.player
589
- if (managed.ended && current != null && managed.prepared) {
590
- try { current.seekTo(0) } catch (e: Exception) { }
591
- managed.ended = false
592
- }
593
- managed.paused = false
594
- if (current != null && managed.prepared) {
595
- applyPlayerRate(managed, current)
596
- try { current.start() } catch (e: Exception) {
597
- throw OneNativeError(E_STATE, "Audio.playerPlay: the audio operation is not ready")
598
- }
599
- }
600
- }
598
+ mainSync("playerPlay") { startManaged(managedPlayer(playerId)) }
601
599
  }
602
600
 
603
601
  override fun playerPause(playerId: String) {
604
602
  mainSync("playerPause") {
605
- val managed = managedPlayer(playerId)
606
- try {
607
- if (managed.player?.isPlaying == true) managed.player?.pause()
608
- } catch (e: Exception) {
609
- }
610
- managed.paused = true
603
+ pauseManaged(managedPlayer(playerId))
611
604
  }
612
605
  }
613
606
 
@@ -639,6 +632,7 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
639
632
  current.setOnSeekCompleteListener { mp ->
640
633
  if (generation != managed.seekGeneration || managed.player !== mp) return@setOnSeekCompleteListener
641
634
  managed.ended = false
635
+ syncManaged(managed)
642
636
  promise.resolve(Unit)
643
637
  }
644
638
  val ms = (seconds * 1000).toLong().coerceAtLeast(0L)
@@ -661,22 +655,30 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
661
655
  if (sourceUri != null) attachPlayerSource(managed, sourceUri, headers)
662
656
  managed.paused = true
663
657
  managed.ended = false
658
+ syncManaged(managed)
664
659
  }
665
660
  }
666
661
 
667
662
  override fun removePlayer(playerId: String) {
668
663
  if (Looper.myLooper() == Looper.getMainLooper()) {
669
- managedPlayers.remove(playerId)?.let { releaseManagedPlayer(it) }
664
+ discardPlayer(playerId)
670
665
  return
671
666
  }
672
667
  val latch = CountDownLatch(1)
673
668
  main.post {
674
- managedPlayers.remove(playerId)?.let { releaseManagedPlayer(it) }
669
+ discardPlayer(playerId)
675
670
  latch.countDown()
676
671
  }
677
672
  latch.await()
678
673
  }
679
674
 
675
+ private fun discardPlayer(playerId: String) {
676
+ val managed = managedPlayers.remove(playerId) ?: return
677
+ if (nowPlayingOwner == NowPlayingOwner.Managed(playerId)) clearNowPlaying()
678
+ releaseManagedPlayer(managed)
679
+ syncService()
680
+ }
681
+
680
682
  override fun getPlayerStatus(playerId: String): AudioPlayerStatus =
681
683
  mainSync("getPlayerStatus") { playerStatus(managedPlayer(playerId)) }
682
684
 
@@ -743,6 +745,142 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
743
745
 
744
746
  private val managedPlayers = mutableMapOf<String, ManagedAudioPlayer>()
745
747
 
748
+ private sealed class NowPlayingOwner {
749
+ object Global : NowPlayingOwner()
750
+ data class Managed(val id: String) : NowPlayingOwner()
751
+ }
752
+
753
+ // a singleton play with a position or paused flag: it resolves after the
754
+ // player is prepared and the seek lands, before any sound.
755
+ private class PendingLoad(val promise: Promise<AudioPlaybackStatus>, val positionMs: Double)
756
+
757
+ private class OwnerState(val playing: Boolean, val positionMs: Double, val durationMs: Double?)
758
+
759
+ private fun seekLoad(current: MediaPlayer, load: PendingLoad) {
760
+ val duration = try { current.duration } catch (e: Exception) { -1 }
761
+ val target = if (duration >= 0) minOf(load.positionMs.toLong(), duration.toLong()) else load.positionMs.toLong()
762
+ val generation = ++seekGeneration
763
+ try {
764
+ current.setOnSeekCompleteListener { mp ->
765
+ // replacement or stop rejects the load in clearPlayer, so a
766
+ // late completion finds a different load and does nothing.
767
+ if (generation != seekGeneration || player !== mp || pendingLoad !== load) {
768
+ return@setOnSeekCompleteListener
769
+ }
770
+ pendingLoad = null
771
+ playbackEnded = false
772
+ if (!playbackPaused) {
773
+ try { mp.start() } catch (e: Exception) { }
774
+ }
775
+ syncService()
776
+ syncNowPlaying()
777
+ load.promise.resolve(playbackStatus())
778
+ }
779
+ if (Build.VERSION.SDK_INT >= 26) {
780
+ current.seekTo(target, MediaPlayer.SEEK_CLOSEST)
781
+ } else {
782
+ current.seekTo(target.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
783
+ }
784
+ } catch (e: Exception) {
785
+ pendingLoad = null
786
+ load.promise.reject(OneNativeError(E_FAILED, "Audio.play: ${e.message ?: "the seek failed"}"))
787
+ }
788
+ }
789
+
790
+ private fun startManaged(managed: ManagedAudioPlayer) {
791
+ requestFocus()
792
+ val current = managed.player
793
+ if (managed.ended && current != null && managed.prepared) {
794
+ try { current.seekTo(0) } catch (e: Exception) { }
795
+ managed.ended = false
796
+ }
797
+ managed.paused = false
798
+ if (current != null && managed.prepared) {
799
+ applyPlayerRate(managed, current)
800
+ try { current.start() } catch (e: Exception) {
801
+ throw OneNativeError(E_STATE, "Audio.playerPlay: the audio operation is not ready")
802
+ }
803
+ }
804
+ syncManaged(managed)
805
+ }
806
+
807
+ private fun pauseManaged(managed: ManagedAudioPlayer) {
808
+ try {
809
+ if (managed.player?.isPlaying == true) managed.player?.pause()
810
+ } catch (e: Exception) {
811
+ }
812
+ managed.paused = true
813
+ syncManaged(managed)
814
+ }
815
+
816
+ private fun ownerManaged(): ManagedAudioPlayer? =
817
+ (nowPlayingOwner as? NowPlayingOwner.Managed)?.let { managedPlayers[it.id] }
818
+
819
+ private fun syncManaged(managed: ManagedAudioPlayer) {
820
+ if (ownerManaged() !== managed) return
821
+ syncService()
822
+ syncNowPlaying()
823
+ }
824
+
825
+ private fun nowPlayingArtwork(verb: String, info: AudioNowPlayingInfo): String? {
826
+ if (info.title.trim().isEmpty()) {
827
+ throw OneNativeError(E_METADATA, "Audio.$verb: now playing title must not be empty")
828
+ }
829
+ val artworkUri = info.artworkUri ?: return null
830
+ val parsed = try {
831
+ Uri.parse(artworkUri)
832
+ } catch (e: Exception) {
833
+ null
834
+ }
835
+ val file = if (parsed?.scheme == "file") parsed.path?.let { File(it) } else null
836
+ if (file == null || !file.exists() || file.isDirectory ||
837
+ BitmapFactory.decodeFile(file.absolutePath) == null
838
+ ) {
839
+ throw OneNativeError(E_ARTWORK, "Audio.$verb: now playing artwork must be an existing image file")
840
+ }
841
+ return file.absolutePath
842
+ }
843
+
844
+ // a transfer keeps the media session and swaps the owner, so the system
845
+ // controls stay up while ownership moves.
846
+ private fun claimNowPlaying(owner: NowPlayingOwner, info: AudioNowPlayingInfo, artworkPath: String?) {
847
+ if (mediaSession == null) {
848
+ val session = MediaSession(context, "OneAudio")
849
+ session.setCallback(
850
+ object : MediaSession.Callback() {
851
+ override fun onPlay() {
852
+ handleRemoteCommand(AudioRemoteCommandType.PLAY, null)
853
+ }
854
+
855
+ override fun onPause() {
856
+ handleRemoteCommand(AudioRemoteCommandType.PAUSE, null)
857
+ }
858
+
859
+ override fun onSeekTo(pos: Long) {
860
+ handleRemoteCommand(AudioRemoteCommandType.SEEK, pos.toDouble())
861
+ }
862
+ }
863
+ )
864
+ mediaSession = session
865
+ }
866
+ nowPlayingOwner = owner
867
+ nowPlayingInfo = info
868
+ nowPlayingArtworkPath = artworkPath
869
+ mediaSession?.isActive = true
870
+ syncService()
871
+ syncNowPlaying()
872
+ }
873
+
874
+ private fun ownerState(): OwnerState? = when (val owner = nowPlayingOwner) {
875
+ is NowPlayingOwner.Global -> if (player == null) null else playbackStatus().let {
876
+ OwnerState(it.state == AudioPlaybackState.PLAYING, it.positionMs, it.durationMs)
877
+ }
878
+ is NowPlayingOwner.Managed -> managedPlayers[owner.id]?.let { playerStatus(it) }?.let {
879
+ OwnerState(it.playing, it.currentTime * 1000, if (it.duration > 0) it.duration * 1000 else null)
880
+ }
881
+ null -> null
882
+ }
883
+
746
884
  private fun <T> mainSync(verb: String, work: () -> T): T {
747
885
  if (Looper.myLooper() == Looper.getMainLooper()) {
748
886
  try {
@@ -820,6 +958,7 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
820
958
  applyPlayerRate(managed, mp)
821
959
  if (!managed.paused && !managed.ended) {
822
960
  try { mp.start() } catch (e: Exception) { }
961
+ syncManaged(managed)
823
962
  } else {
824
963
  // a freshly prepared MediaPlayer can report isPlaying on
825
964
  // some emulators without ever being started; stop any
@@ -834,10 +973,12 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
834
973
  fresh.setOnCompletionListener { mp ->
835
974
  if (managed.player !== mp) return@setOnCompletionListener
836
975
  managed.ended = true
976
+ syncManaged(managed)
837
977
  }
838
978
  fresh.setOnErrorListener { mp, what, extra ->
839
979
  if (managed.player !== mp) return@setOnErrorListener true
840
980
  managed.error = "playback failed ($what/$extra)"
981
+ syncManaged(managed)
841
982
  true
842
983
  }
843
984
  managed.player = fresh
@@ -944,7 +1085,7 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
944
1085
  } catch (e: Exception) {
945
1086
  }
946
1087
  }
947
- stopService()
1088
+ syncService()
948
1089
  syncNowPlaying()
949
1090
  emitInterruption(AudioInterruptionEvent(AudioInterruptionType.BEGAN, false))
950
1091
  }
@@ -970,42 +1111,75 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
970
1111
 
971
1112
  private fun handleRemoteCommand(type: AudioRemoteCommandType, positionMs: Double?) {
972
1113
  main.post {
973
- val current = player
974
- if (current == null || mediaSession == null) return@post
975
- when (type) {
976
- AudioRemoteCommandType.PLAY -> {
977
- try {
978
- if (playbackEnded) {
979
- current.seekTo(0)
1114
+ if (mediaSession == null) return@post
1115
+ val owner = nowPlayingOwner ?: return@post
1116
+ if (type == AudioRemoteCommandType.SEEK &&
1117
+ (positionMs == null || !positionMs.isFinite() || positionMs < 0)
1118
+ ) return@post
1119
+ val seekMs = (positionMs ?: 0.0).toLong().coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
1120
+ var playerId: String? = null
1121
+ when (owner) {
1122
+ is NowPlayingOwner.Global -> {
1123
+ val current = player ?: return@post
1124
+ when (type) {
1125
+ AudioRemoteCommandType.PLAY -> {
1126
+ try {
1127
+ if (playbackEnded) {
1128
+ current.seekTo(0)
1129
+ playbackEnded = false
1130
+ }
1131
+ playbackPaused = false
1132
+ if (playerPrepared && pendingLoad == null) current.start()
1133
+ } catch (e: Exception) {
1134
+ return@post
1135
+ }
1136
+ }
1137
+ AudioRemoteCommandType.PAUSE -> {
1138
+ try {
1139
+ if (current.isPlaying) current.pause()
1140
+ } catch (e: Exception) {
1141
+ }
1142
+ playbackPaused = true
1143
+ }
1144
+ AudioRemoteCommandType.SEEK -> {
1145
+ if (pendingLoad != null) return@post
1146
+ try {
1147
+ current.seekTo(seekMs)
1148
+ } catch (e: Exception) {
1149
+ return@post
1150
+ }
980
1151
  playbackEnded = false
981
1152
  }
982
- playbackPaused = false
983
- if (playerPrepared) current.start()
984
- startServiceIfBackground()
985
- } catch (e: Exception) {
986
- return@post
987
- }
988
- }
989
- AudioRemoteCommandType.PAUSE -> {
990
- try {
991
- if (current.isPlaying) current.pause()
992
- } catch (e: Exception) {
993
1153
  }
994
- playbackPaused = true
995
- stopService()
996
1154
  }
997
- AudioRemoteCommandType.SEEK -> {
998
- if (positionMs == null || !positionMs.isFinite() || positionMs < 0) return@post
999
- try {
1000
- current.seekTo(positionMs.toLong().coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
1001
- } catch (e: Exception) {
1002
- return@post
1155
+ is NowPlayingOwner.Managed -> {
1156
+ val managed = managedPlayers[owner.id] ?: return@post
1157
+ playerId = owner.id
1158
+ when (type) {
1159
+ AudioRemoteCommandType.PLAY -> try {
1160
+ startManaged(managed)
1161
+ } catch (e: Exception) {
1162
+ return@post
1163
+ }
1164
+ AudioRemoteCommandType.PAUSE -> pauseManaged(managed)
1165
+ AudioRemoteCommandType.SEEK -> {
1166
+ val current = managed.player
1167
+ if (current == null || !managed.prepared) return@post
1168
+ // retire any pending playerSeek listener first.
1169
+ managed.seekGeneration += 1
1170
+ try {
1171
+ current.seekTo(seekMs)
1172
+ } catch (e: Exception) {
1173
+ return@post
1174
+ }
1175
+ managed.ended = false
1176
+ }
1003
1177
  }
1004
- playbackEnded = false
1005
1178
  }
1006
1179
  }
1180
+ syncService()
1007
1181
  syncNowPlaying()
1008
- val event = AudioRemoteCommandEvent(type, positionMs)
1182
+ val event = AudioRemoteCommandEvent(type, positionMs, playerId)
1009
1183
  val listeners = synchronized(lock) { remoteCommandListeners.values.toList() }
1010
1184
  for (listener in listeners) {
1011
1185
  try {
@@ -1022,7 +1196,7 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
1022
1196
  return AudioPlaybackStatus(AudioPlaybackState.IDLE, null, 0.0, null, null)
1023
1197
  }
1024
1198
  // duration and position are unavailable until preparation completes.
1025
- if (!playerPrepared || playbackError != null) {
1199
+ if (!playerPrepared || pendingLoad != null || playbackError != null) {
1026
1200
  return AudioPlaybackStatus(
1027
1201
  if (playbackError != null) AudioPlaybackState.FAILED else AudioPlaybackState.LOADING,
1028
1202
  playerUri,
@@ -1083,8 +1257,8 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
1083
1257
  private fun syncNowPlaying() {
1084
1258
  val session = mediaSession
1085
1259
  val info = nowPlayingInfo
1086
- val current = player
1087
- if (session == null || info == null || current == null) return
1260
+ val status = ownerState()
1261
+ if (session == null || info == null || status == null) return
1088
1262
  val metadata = MediaMetadata.Builder()
1089
1263
  .putString(MediaMetadata.METADATA_KEY_TITLE, info.title)
1090
1264
  info.artist?.let { metadata.putString(MediaMetadata.METADATA_KEY_ARTIST, it) }
@@ -1097,11 +1271,10 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
1097
1271
  } catch (e: Exception) {
1098
1272
  }
1099
1273
  }
1100
- val status = playbackStatus()
1101
1274
  status.durationMs?.let { metadata.putLong(MediaMetadata.METADATA_KEY_DURATION, it.toLong()) }
1102
1275
  session.setMetadata(metadata.build())
1103
1276
  val position = status.positionMs.toLong()
1104
- val playing = status.state == AudioPlaybackState.PLAYING
1277
+ val playing = status.playing
1105
1278
  session.setPlaybackState(
1106
1279
  PlaybackState.Builder()
1107
1280
  .setActions(
@@ -1126,10 +1299,15 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
1126
1299
  mediaSession = null
1127
1300
  nowPlayingInfo = null
1128
1301
  nowPlayingArtworkPath = null
1302
+ nowPlayingOwner = null
1129
1303
  }
1130
1304
 
1131
1305
  private fun clearPlayer() {
1132
- clearNowPlaying()
1306
+ if (nowPlayingOwner == NowPlayingOwner.Global) clearNowPlaying()
1307
+ pendingLoad?.let { load ->
1308
+ pendingLoad = null
1309
+ load.promise.reject(OneNativeError(E_STATE, "Audio.play: playback was replaced before it was ready"))
1310
+ }
1133
1311
  seekGeneration++
1134
1312
  try {
1135
1313
  player?.setOnSeekCompleteListener(null)
@@ -1149,7 +1327,7 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
1149
1327
  playbackPaused = false
1150
1328
  playbackEnded = false
1151
1329
  playbackError = null
1152
- stopService()
1330
+ syncService()
1153
1331
  }
1154
1332
 
1155
1333
  private fun teardownRecorder(deleteFile: Boolean) {
@@ -1249,14 +1427,21 @@ class HybridOneAudio : HybridOneAudioSpec(), PermissionListener {
1249
1427
  }
1250
1428
  }
1251
1429
 
1252
- private fun startServiceIfBackground() {
1430
+ // the foreground service runs while the singleton or the managed now
1431
+ // playing owner is playing.
1432
+ private fun syncService() {
1253
1433
  if (!backgroundEnabled()) return
1254
- OneAudioService.start(context, nowPlayingInfo?.title ?: "Audio playing")
1255
- }
1256
-
1257
- private fun stopService() {
1258
- if (!backgroundEnabled()) return
1259
- OneAudioService.stop(context)
1434
+ val owner = ownerManaged()
1435
+ val playing = (player != null && playerPrepared && pendingLoad == null && !playbackPaused &&
1436
+ !playbackEnded && playbackError == null) ||
1437
+ (owner != null && owner.player != null && !owner.paused && !owner.ended && owner.error == null)
1438
+ if (playing) {
1439
+ OneAudioService.start(context, nowPlayingInfo?.title ?: "Audio playing")
1440
+ serviceRunning = true
1441
+ } else if (serviceRunning) {
1442
+ OneAudioService.stop(context)
1443
+ serviceRunning = false
1444
+ }
1260
1445
  }
1261
1446
 
1262
1447
  private fun permissionStatus(): AudioRecordingPermission {