@5stones/react-native-audio-browser 0.1.5 → 0.2.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 (44) hide show
  1. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +34 -9
  2. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +57 -17
  3. package/android/src/main/java/com/audiobrowser/player/TransformingDataSource.kt +33 -11
  4. package/ios/Browser/BrowserManager.swift +2 -17
  5. package/ios/CarPlay/CarPlayController.swift +75 -27
  6. package/ios/HybridAudioBrowser.swift +42 -12
  7. package/ios/TrackPlayer.swift +39 -37
  8. package/lib/commonjs/features/remoteControls.js +24 -1
  9. package/lib/commonjs/features/remoteControls.js.map +1 -1
  10. package/lib/commonjs/web/NativeAudioBrowser.js +34 -5
  11. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  12. package/lib/module/features/remoteControls.js +22 -0
  13. package/lib/module/features/remoteControls.js.map +1 -1
  14. package/lib/module/web/NativeAudioBrowser.js +34 -5
  15. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  16. package/lib/typescript/src/features/remoteControls.d.ts +26 -0
  17. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  18. package/lib/typescript/src/specs/audio-browser.nitro.d.ts +3 -1
  19. package/lib/typescript/src/specs/audio-browser.nitro.d.ts.map +1 -1
  20. package/lib/typescript/src/web/NativeAudioBrowser.d.ts +11 -1
  21. package/lib/typescript/src/web/NativeAudioBrowser.d.ts.map +1 -1
  22. package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
  23. package/nitrogen/generated/android/c++/JFunc_void_RemoteLoadEvent.hpp +89 -0
  24. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +39 -0
  25. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +4 -0
  26. package/nitrogen/generated/android/c++/JRemoteLoadEvent.hpp +94 -0
  27. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_void_RemoteLoadEvent.kt +80 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +28 -0
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteLoadEvent.kt +44 -0
  30. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +8 -0
  31. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +40 -0
  32. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +3 -0
  33. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +17 -0
  34. package/nitrogen/generated/ios/swift/Func_void_RemoteLoadEvent.swift +47 -0
  35. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +2 -0
  36. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +51 -0
  37. package/nitrogen/generated/ios/swift/RemoteLoadEvent.swift +70 -0
  38. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +4 -0
  39. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +7 -0
  40. package/nitrogen/generated/shared/c++/RemoteLoadEvent.hpp +85 -0
  41. package/package.json +1 -1
  42. package/src/features/remoteControls.ts +35 -0
  43. package/src/specs/audio-browser.nitro.ts +3 -0
  44. package/src/web/NativeAudioBrowser.ts +34 -5
@@ -65,6 +65,7 @@ import com.margelo.nitro.audiobrowser.PlayingState
65
65
  import com.margelo.nitro.audiobrowser.Progress
66
66
  import com.margelo.nitro.audiobrowser.RemoteJumpBackwardEvent
67
67
  import com.margelo.nitro.audiobrowser.RemoteJumpForwardEvent
68
+ import com.margelo.nitro.audiobrowser.RemoteLoadEvent
68
69
  import com.margelo.nitro.audiobrowser.RemotePlayIdEvent
69
70
  import com.margelo.nitro.audiobrowser.RemotePlaySearchEvent
70
71
  import com.margelo.nitro.audiobrowser.RemoteSeekEvent
@@ -149,6 +150,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
149
150
  override var onRemoteJumpBackward: (RemoteJumpBackwardEvent) -> Unit = {}
150
151
  override var onRemoteJumpForward: (RemoteJumpForwardEvent) -> Unit = {}
151
152
  override var onRemoteLike: () -> Unit = {}
153
+ override var onRemoteLoad: (RemoteLoadEvent) -> Unit = {}
152
154
  override var onRemoteNext: () -> Unit = {}
153
155
  override var onRemotePause: () -> Unit = {}
154
156
  override var onChapterMetadata: (chapters: Array<ChapterMetadata>) -> Unit = {}
@@ -190,6 +192,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
190
192
  override var handleRemoteJumpBackward: ((RemoteJumpBackwardEvent) -> Unit)? = null
191
193
  override var handleRemoteJumpForward: ((RemoteJumpForwardEvent) -> Unit)? = null
192
194
  override var handleRemoteLike: (() -> Unit)? = null
195
+ override var handleRemoteLoad: ((RemoteLoadEvent) -> Unit)? = null
193
196
  override var handleRemoteNext: (() -> Unit)? = null
194
197
  override var handleRemotePause: (() -> Unit)? = null
195
198
  override var handleRemotePlay: (() -> Unit)? = null
@@ -504,6 +507,24 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
504
507
  }
505
508
  }
506
509
 
510
+ /**
511
+ * Centralizes handleRemoteLoad interception logic.
512
+ * If handleRemoteLoad is set, calls it (intercepted). Otherwise runs defaultBehavior.
513
+ * Always fires onRemoteLoad afterward.
514
+ *
515
+ * @return true if the handler intercepted, false if defaultBehavior ran
516
+ */
517
+ private fun handleLoad(
518
+ track: Track, queue: Array<Track>, startIndex: Double,
519
+ defaultBehavior: () -> Unit,
520
+ ): Boolean {
521
+ val event = RemoteLoadEvent(track, queue, startIndex)
522
+ val handled = handleRemoteLoad?.let { it.invoke(event); true } ?: false
523
+ if (!handled) defaultBehavior()
524
+ onRemoteLoad(event)
525
+ return handled
526
+ }
527
+
507
528
  override fun navigateTrack(track: Track) {
508
529
  clearNavigationError()
509
530
 
@@ -527,8 +548,10 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
527
548
  val index = player.tracks.indexOfFirst { it.src == trackId }
528
549
  if (index >= 0) {
529
550
  Timber.d("Queue already from $parentPath, skipping to index $index")
530
- player.skipTo(index)
531
- player.play()
551
+ handleLoad(track, player.tracks, index.toDouble()) {
552
+ player.skipTo(index)
553
+ player.play()
554
+ }
532
555
  return@launch
533
556
  }
534
557
  }
@@ -541,15 +564,17 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
541
564
  Timber.d(
542
565
  "Loading expanded queue: ${tracks.size} tracks, starting at index $startIndex"
543
566
  )
544
-
545
- // Replace queue and seek to selected track
546
- // Use internal player methods directly to avoid blocking on main thread
547
- player.setQueue(tracks, startIndex, sourcePath = parentPath)
548
- player.play()
567
+ handleLoad(track, tracks, startIndex.toDouble()) {
568
+ // Replace queue and seek to selected track
569
+ // Use internal player methods directly to avoid blocking on main thread
570
+ player.setQueue(tracks, startIndex, sourcePath = parentPath)
571
+ player.play()
572
+ }
573
+ return@launch
549
574
  } else {
550
575
  // Fallback: just load the single track
551
576
  Timber.w("Queue expansion failed, loading single track")
552
- player.load(track)
577
+ handleLoad(track, arrayOf(track), 0.0) { player.load(track) }
553
578
  }
554
579
  }
555
580
  // Navigate to browsable track to show browsing UI
@@ -560,7 +585,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
560
585
  // If track is playable (has src), load it into player
561
586
  track.src != null -> {
562
587
  Timber.d("Loading playable track into player: ${track.title}")
563
- player.load(track)
588
+ handleLoad(track, arrayOf(track), 0.0) { player.load(track) }
564
589
  }
565
590
  else -> {
566
591
  throw IllegalArgumentException("Track must have either an 'url' or an 'src' property")
@@ -12,10 +12,13 @@ import androidx.media3.session.MediaConstants
12
12
  import androidx.media3.session.SessionCommand
13
13
  import androidx.media3.session.SessionError
14
14
  import androidx.media3.session.SessionResult
15
+ import com.audiobrowser.AudioBrowser
15
16
  import com.audiobrowser.util.BrowserPathHelper
16
17
  import com.audiobrowser.util.RatingFactory
17
18
  import com.audiobrowser.util.ResolvedTrackFactory
18
19
  import com.audiobrowser.util.TrackFactory
20
+ import com.margelo.nitro.audiobrowser.RemoteLoadEvent
21
+ import com.margelo.nitro.audiobrowser.Track
19
22
  import com.google.common.collect.ImmutableList
20
23
  import com.google.common.util.concurrent.Futures
21
24
  import com.google.common.util.concurrent.ListenableFuture
@@ -451,6 +454,24 @@ class MediaSessionCallback(private val player: Player) :
451
454
  }
452
455
  }
453
456
 
457
+ /**
458
+ * Centralizes handleRemoteLoad interception logic for onSetMediaItems.
459
+ * If handleRemoteLoad is set, calls it and returns interceptedResult().
460
+ * Otherwise runs defaultBehavior(). Always fires onRemoteLoad afterward.
461
+ */
462
+ private suspend fun handleLoad(
463
+ audioBrowser: AudioBrowser,
464
+ track: Track, queue: Array<Track>, startIndex: Double,
465
+ interceptedResult: () -> MediaSession.MediaItemsWithStartPosition,
466
+ defaultBehavior: suspend () -> MediaSession.MediaItemsWithStartPosition,
467
+ ): MediaSession.MediaItemsWithStartPosition {
468
+ val event = RemoteLoadEvent(track, queue, startIndex)
469
+ val handler = audioBrowser.handleRemoteLoad
470
+ val result = if (handler != null) { handler(event); interceptedResult() } else defaultBehavior()
471
+ audioBrowser.onRemoteLoad(event)
472
+ return result
473
+ }
474
+
454
475
  override fun onSetMediaItems(
455
476
  mediaSession: MediaSession,
456
477
  controller: MediaSession.ControllerInfo,
@@ -463,6 +484,19 @@ class MediaSessionCallback(private val player: Player) :
463
484
  )
464
485
 
465
486
  return scope.future {
487
+ val audioBrowser = player.awaitBrowser()
488
+
489
+ // Helper: returns the current player state unchanged so Media3 doesn't modify playback
490
+ fun currentPlayerState(): MediaSession.MediaItemsWithStartPosition {
491
+ val currentItems = player.tracks.map { TrackFactory.toMedia3(it) }
492
+ val currentIndex = player.currentIndex ?: 0
493
+ return MediaSession.MediaItemsWithStartPosition(
494
+ currentItems,
495
+ currentIndex,
496
+ startPositionMs,
497
+ )
498
+ }
499
+
466
500
  // Check if this is a single contextual URL that matches the current queue source
467
501
  if (mediaItems.size == 1) {
468
502
  val mediaId = mediaItems[0].mediaId
@@ -475,33 +509,39 @@ class MediaSessionCallback(private val player: Player) :
475
509
  val index = player.tracks.indexOfFirst { it.src == trackId }
476
510
  if (index >= 0) {
477
511
  Timber.d("Queue already from $parentPath, skipping to index $index")
478
- // Return the existing queue items with the new start index
479
- val existingItems = player.tracks.map { TrackFactory.toMedia3(it) }
480
- return@future MediaSession.MediaItemsWithStartPosition(
481
- existingItems,
482
- index,
483
- startPositionMs,
484
- )
512
+ val track = player.tracks[index]
513
+ return@future handleLoad(audioBrowser, track, player.tracks, index.toDouble(), ::currentPlayerState) {
514
+ // Return the existing queue items with the new start index
515
+ val existingItems = player.tracks.map { TrackFactory.toMedia3(it) }
516
+ MediaSession.MediaItemsWithStartPosition(
517
+ existingItems,
518
+ index,
519
+ startPositionMs,
520
+ )
521
+ }
485
522
  }
486
523
  }
487
524
  }
488
525
  }
489
526
 
490
- // Wait for browser to be registered if it's not available yet
491
- val browserManager = player.awaitBrowser().browserManager
527
+ val browserManager = audioBrowser.browserManager
492
528
  val result =
493
529
  browserManager.resolveMediaItemsForPlayback(mediaItems, startIndex, startPositionMs)
494
530
 
495
- // If this was a contextual URL expansion, track the source path
496
- if (mediaItems.size == 1) {
497
- val mediaId = mediaItems[0].mediaId
498
- if (BrowserPathHelper.isContextual(mediaId)) {
499
- val parentPath = BrowserPathHelper.stripTrackId(mediaId)
500
- withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
531
+ val tracks = result.mediaItems.map { TrackFactory.fromMedia3(it) }.toTypedArray()
532
+ val selectedTrack = tracks.getOrElse(result.startIndex) { tracks.first() }
533
+
534
+ handleLoad(audioBrowser, selectedTrack, tracks, result.startIndex.toDouble(), ::currentPlayerState) {
535
+ // If this was a contextual URL expansion, track the source path (only for default behavior)
536
+ if (mediaItems.size == 1) {
537
+ val mediaId = mediaItems[0].mediaId
538
+ if (BrowserPathHelper.isContextual(mediaId)) {
539
+ val parentPath = BrowserPathHelper.stripTrackId(mediaId)
540
+ withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
541
+ }
501
542
  }
543
+ result
502
544
  }
503
-
504
- result
505
545
  }
506
546
  }
507
547
 
@@ -17,10 +17,14 @@ import timber.log.Timber
17
17
  * needs to fetch data. By deferring URL transformation (which may invoke a JS callback
18
18
  * via `runBlocking`) to [open], we avoid blocking the main thread and prevent deadlocks
19
19
  * when the JS thread is occupied by synchronous Nitro calls (e.g., `seekTo`).
20
+ *
21
+ * The transform is resolved once per track on the first [open] call (the manifest/media
22
+ * URL). The resulting headers and user-agent are cached on the [Factory] and reused for
23
+ * subsequent requests (segments, encryption keys) without calling the JS transform again.
20
24
  */
21
25
  class TransformingDataSource(
22
26
  private val upstream: DataSource,
23
- private val getRequestConfig: (originalUrl: String) -> MediaRequestConfig?,
27
+ private val factory: Factory,
24
28
  ) : DataSource {
25
29
 
26
30
  companion object {
@@ -32,11 +36,19 @@ class TransformingDataSource(
32
36
  override fun open(dataSpec: DataSpec): Long {
33
37
  val originalUrl = dataSpec.uri.toString()
34
38
 
35
- // Resolve the transform on the IO thread safe to block here since
36
- // the main thread and JS thread remain free for other work.
37
- val (finalUrl, headers, userAgent) = resolveRequestConfig(originalUrl)
38
-
39
- Timber.d("TransformingDataSource: $originalUrl -> $finalUrl")
39
+ // Check if the Factory already has a cached transform result.
40
+ // The first DataSource to open() resolves the transform and caches it.
41
+ // All subsequent opens (segments, keys, replays) reuse the cached
42
+ // headers/user-agent but keep their original URLs.
43
+ val cached = factory.cachedTransform
44
+ val (finalUrl, headers, userAgent) = if (cached == null) {
45
+ val resolved = resolveRequestConfig(originalUrl)
46
+ factory.cachedTransform = resolved
47
+ Timber.d("TransformingDataSource: resolved $originalUrl -> ${resolved.first}")
48
+ resolved
49
+ } else {
50
+ Triple(originalUrl, cached.second, cached.third)
51
+ }
40
52
 
41
53
  // Build merged headers including user-agent override
42
54
  val mergedHeaders = buildMap {
@@ -47,7 +59,7 @@ class TransformingDataSource(
47
59
  }
48
60
  }
49
61
 
50
- // Build a new DataSpec with the transformed URL and merged headers
62
+ // Build a new DataSpec with the (possibly transformed) URL and merged headers
51
63
  val transformedSpec =
52
64
  dataSpec.buildUpon().setUri(finalUrl.toUri()).setHttpRequestHeaders(mergedHeaders).build()
53
65
 
@@ -82,7 +94,7 @@ class TransformingDataSource(
82
94
  originalUrl: String
83
95
  ): Triple<String, Map<String, String>, String> {
84
96
  return try {
85
- val requestConfig = getRequestConfig(originalUrl)
97
+ val requestConfig = factory.getRequestConfig(originalUrl)
86
98
  if (requestConfig != null) {
87
99
  val path = requestConfig.path ?: ""
88
100
  val baseUrl = requestConfig.baseUrl
@@ -91,7 +103,7 @@ class TransformingDataSource(
91
103
  val finalUrl =
92
104
  if (requestConfig.query?.isNotEmpty() == true) {
93
105
  val uri = Uri.parse(url).buildUpon()
94
- requestConfig.query!!.forEach { (key, value) -> uri.appendQueryParameter(key, value) }
106
+ requestConfig.query?.forEach { (key, value) -> uri.appendQueryParameter(key, value) }
95
107
  uri.build().toString()
96
108
  } else {
97
109
  url
@@ -113,12 +125,22 @@ class TransformingDataSource(
113
125
  /** Factory that creates [TransformingDataSource] instances wrapping an upstream factory. */
114
126
  class Factory(
115
127
  private val upstreamFactory: DataSource.Factory,
116
- private val getRequestConfig: (originalUrl: String) -> MediaRequestConfig?,
128
+ internal val getRequestConfig: (originalUrl: String) -> MediaRequestConfig?,
117
129
  ) : DataSource.Factory {
130
+
131
+ /**
132
+ * Cached transform result (url, headers, userAgent) from the first open() call.
133
+ * Shared across all DataSource instances created by this factory.
134
+ * A new Factory is created per [MediaFactory.createMediaSource] call (per track),
135
+ * so the cache naturally resets on track transitions.
136
+ */
137
+ @Volatile
138
+ internal var cachedTransform: Triple<String, Map<String, String>, String>? = null
139
+
118
140
  override fun createDataSource(): DataSource {
119
141
  return TransformingDataSource(
120
142
  upstream = upstreamFactory.createDataSource(),
121
- getRequestConfig = getRequestConfig,
143
+ factory = this,
122
144
  )
123
145
  }
124
146
  }
@@ -744,26 +744,11 @@ final class BrowserManager: @unchecked Sendable {
744
744
 
745
745
  logger.debug("resolveMediaUrl: calling transform callback...")
746
746
  // MediaRequestConfig.transform takes (request, routeParams) - pass nil for routeParams
747
- // Call the JS transform outside the task group to avoid sending-parameter
748
- // data-race warnings (transform and baseRequest aren't Sendable).
749
747
  let outerPromise = transform(baseRequest, nil)
748
+ logger.debug("resolveMediaUrl: awaiting outer promise...")
750
749
  let innerPromise = try await outerPromise.await()
751
750
  logger.debug("resolveMediaUrl: awaiting inner promise...")
752
- let transformedConfig = try await withThrowingTaskGroup(of: RequestConfig.self) { group in
753
- group.addTask { [innerPromise] in
754
- return try await innerPromise.await()
755
- }
756
- group.addTask {
757
- // Safety-net timeout: if the JS transform Promise doesn't resolve
758
- // within 10s, bail out. This guards against Nitro bridge hangs when
759
- // concurrent callbacks race.
760
- try await Task.sleep(nanoseconds: 10_000_000_000)
761
- throw CancellationError()
762
- }
763
- let result = try await group.next()!
764
- group.cancelAll()
765
- return result
766
- }
751
+ let transformedConfig = try await innerPromise.await()
767
752
  logger.debug("resolveMediaUrl: transform complete")
768
753
 
769
754
  // Extract values immediately to Swift native types to avoid
@@ -438,6 +438,55 @@ public final class RNABCarPlayController: NSObject {
438
438
 
439
439
  // MARK: - Selection Handling
440
440
 
441
+ /// Centralizes handleRemoteLoad interception for synchronous CarPlay selection paths.
442
+ /// If handleRemoteLoad is set, calls it (intercepted). Otherwise runs defaultBehavior.
443
+ /// Always fires onRemoteLoad, showNowPlaying, and completion afterward.
444
+ @discardableResult
445
+ private func handleLoad(
446
+ track: Track, queue: [Track], startIndex: Double,
447
+ audioBrowser: HybridAudioBrowser,
448
+ completion: @escaping () -> Void,
449
+ defaultBehavior: () -> Void
450
+ ) -> Bool {
451
+ let event = RemoteLoadEvent(track: track, queue: queue, startIndex: startIndex)
452
+ let handled: Bool
453
+ if let handler = audioBrowser.handleRemoteLoad {
454
+ handler(event)
455
+ handled = true
456
+ } else {
457
+ defaultBehavior()
458
+ handled = false
459
+ }
460
+ audioBrowser.onRemoteLoad(event)
461
+ showNowPlaying()
462
+ completion()
463
+ return handled
464
+ }
465
+
466
+ /// Centralizes handleRemoteLoad interception for async CarPlay selection paths (queue expansion).
467
+ /// If handleRemoteLoad is set, calls it (intercepted). Otherwise runs defaultBehavior.
468
+ /// Always fires onRemoteLoad, showNowPlaying, and completion afterward.
469
+ @discardableResult
470
+ private func handleLoadAsync(
471
+ track: Track, queue: [Track], startIndex: Double,
472
+ audioBrowser: HybridAudioBrowser,
473
+ completion: @escaping () -> Void,
474
+ defaultBehavior: () async -> Void
475
+ ) async -> Bool {
476
+ let event = RemoteLoadEvent(track: track, queue: queue, startIndex: startIndex)
477
+ let handled: Bool
478
+ if let handler = audioBrowser.handleRemoteLoad {
479
+ handler(event)
480
+ handled = true
481
+ } else {
482
+ await defaultBehavior()
483
+ handled = false
484
+ }
485
+ audioBrowser.onRemoteLoad(event)
486
+ await MainActor.run { self.showNowPlaying(); completion() }
487
+ return handled
488
+ }
489
+
441
490
  private func handleItemSelection(track: Track, completion: @escaping () -> Void) {
442
491
  logger.info("Selected track: \(track.title)")
443
492
 
@@ -465,10 +514,10 @@ public final class RNABCarPlayController: NSObject {
465
514
  parentPath == player?.queueSourcePath,
466
515
  let index = player?.tracks.firstIndex(where: { $0.src == trackId })
467
516
  {
468
- logger.debug("Queue already from \(parentPath), skipping to index \(index)")
469
- try? player?.skipTo(index, playWhenReady: true)
470
- showNowPlaying()
471
- completion()
517
+ let queue = player?.tracks ?? []
518
+ handleLoad(track: track, queue: queue, startIndex: Double(index), audioBrowser: audioBrowser, completion: completion) {
519
+ try? player?.skipTo(index, playWhenReady: true)
520
+ }
472
521
  return
473
522
  }
474
523
 
@@ -477,42 +526,41 @@ public final class RNABCarPlayController: NSObject {
477
526
  // Expand the queue from the contextual URL
478
527
  if let expanded = try await audioBrowser.browserManager.expandQueueFromContextualUrl(url) {
479
528
  let (tracks, startIndex) = expanded
480
-
481
- await MainActor.run {
482
- // Replace queue and start at the selected track
483
- audioBrowser.getPlayer()?.setQueue(tracks, initialIndex: startIndex, playWhenReady: true, sourcePath: parentPath)
484
-
485
- // Show now playing template
486
- self.showNowPlaying()
487
- completion()
529
+ await handleLoadAsync(track: track, queue: tracks, startIndex: Double(startIndex), audioBrowser: audioBrowser, completion: completion) {
530
+ await MainActor.run {
531
+ // Replace queue and start at the selected track
532
+ audioBrowser.getPlayer()?.setQueue(tracks, initialIndex: startIndex, playWhenReady: true, sourcePath: parentPath)
533
+ }
488
534
  }
489
535
  } else {
490
536
  // Fallback: just load the single track
491
- await MainActor.run {
492
- try? audioBrowser.load(track: track)
493
- try? audioBrowser.play()
494
- self.showNowPlaying()
495
- completion()
537
+ await handleLoadAsync(track: track, queue: [track], startIndex: 0, audioBrowser: audioBrowser, completion: completion) {
538
+ await MainActor.run {
539
+ try? audioBrowser.load(track: track)
540
+ try? audioBrowser.play()
541
+ }
496
542
  }
497
543
  }
498
544
  } catch {
499
545
  logger.error("Error expanding queue: \(error.localizedDescription)")
500
- await MainActor.run {
501
- try? audioBrowser.load(track: track)
502
- try? audioBrowser.play()
503
- self.showNowPlaying()
504
- completion()
546
+ await handleLoadAsync(track: track, queue: [track], startIndex: 0, audioBrowser: audioBrowser, completion: completion) {
547
+ await MainActor.run {
548
+ try? audioBrowser.load(track: track)
549
+ try? audioBrowser.play()
550
+ }
505
551
  }
506
552
  }
507
553
  }
508
554
  }
509
555
  // If track has src, it's playable - load it
510
556
  else if track.src != nil {
511
- Task { @MainActor in
512
- try? audioBrowser.load(track: track)
513
- try? audioBrowser.play()
514
- showNowPlaying()
515
- completion()
557
+ Task {
558
+ await handleLoadAsync(track: track, queue: [track], startIndex: 0, audioBrowser: audioBrowser, completion: completion) {
559
+ await MainActor.run {
560
+ try? audioBrowser.load(track: track)
561
+ try? audioBrowser.play()
562
+ }
563
+ }
516
564
  }
517
565
  }
518
566
  // If track has url, it's browsable - navigate to it
@@ -175,6 +175,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
175
175
  public var onRemoteJumpBackward: (RemoteJumpBackwardEvent) -> Void = { _ in }
176
176
  public var onRemoteJumpForward: (RemoteJumpForwardEvent) -> Void = { _ in }
177
177
  public var onRemoteLike: () -> Void = {}
178
+ public var onRemoteLoad: (RemoteLoadEvent) -> Void = { _ in }
178
179
  public var onRemoteNext: () -> Void = {}
179
180
  public var onRemotePause: () -> Void = {}
180
181
  public var onRemotePlay: () -> Void = {}
@@ -193,6 +194,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
193
194
  public var handleRemoteJumpBackward: ((RemoteJumpBackwardEvent) -> Void)?
194
195
  public var handleRemoteJumpForward: ((RemoteJumpForwardEvent) -> Void)?
195
196
  public var handleRemoteLike: (() -> Void)?
197
+ public var handleRemoteLoad: ((RemoteLoadEvent) -> Void)?
196
198
  public var handleRemoteNext: (() -> Void)?
197
199
  public var handleRemotePause: (() -> Void)?
198
200
  public var handleRemotePlay: (() -> Void)?
@@ -373,6 +375,24 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
373
375
  }
374
376
  }
375
377
 
378
+ /// Centralizes handleRemoteLoad interception logic.
379
+ /// If handleRemoteLoad is set, calls it (intercepted). Otherwise runs defaultBehavior.
380
+ /// Always fires onRemoteLoad afterward.
381
+ ///
382
+ /// - Returns: true if the handler intercepted, false if defaultBehavior ran
383
+ @discardableResult
384
+ private func handleLoad(track: Track, queue: [Track], startIndex: Double, defaultBehavior: () -> Void) -> Bool {
385
+ let event = RemoteLoadEvent(track: track, queue: queue, startIndex: startIndex)
386
+ if let handler = handleRemoteLoad {
387
+ handler(event)
388
+ onRemoteLoad(event)
389
+ return true
390
+ }
391
+ defaultBehavior()
392
+ onRemoteLoad(event)
393
+ return false
394
+ }
395
+
376
396
  public func navigateTrack(track: Track) throws {
377
397
  let url = track.url
378
398
 
@@ -391,8 +411,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
391
411
  }
392
412
  if let index = existingIndex {
393
413
  logger.debug("Queue already from \(parentPath), skipping to index \(index)")
394
- onMainActor {
395
- try? player?.skipTo(index, playWhenReady: true)
414
+ let queue: [Track] = onMainActor { player?.tracks ?? [] }
415
+ handleLoad(track: track, queue: queue, startIndex: Double(index)) {
416
+ onMainActor {
417
+ try? player?.skipTo(index, playWhenReady: true)
418
+ }
396
419
  }
397
420
  return
398
421
  }
@@ -402,30 +425,37 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
402
425
  // Expand the queue from the contextual URL
403
426
  if let expanded = try await browserManager.expandQueueFromContextualUrl(url) {
404
427
  let (tracks, startIndex) = expanded
405
-
406
- // Replace queue and start at the selected track (auto-play)
407
- await MainActor.run {
408
- player?.setQueue(tracks, initialIndex: startIndex, playWhenReady: true, sourcePath: parentPath)
428
+ handleLoad(track: track, queue: tracks, startIndex: Double(startIndex)) {
429
+ // Replace queue and start at the selected track (auto-play)
430
+ onMainActor {
431
+ player?.setQueue(tracks, initialIndex: startIndex, playWhenReady: true, sourcePath: parentPath)
432
+ }
409
433
  }
410
434
  } else {
411
435
  // Fallback: just load the single track (auto-play)
412
- await MainActor.run {
413
- player?.load(track, playWhenReady: true)
436
+ handleLoad(track: track, queue: [track], startIndex: 0) {
437
+ onMainActor {
438
+ player?.load(track, playWhenReady: true)
439
+ }
414
440
  }
415
441
  }
416
442
  } catch {
417
443
  logger.error("Error expanding queue: \(error.localizedDescription)")
418
444
  // Fallback to single track (auto-play) - playback errors reported via TrackPlayer callbacks
419
- await MainActor.run {
420
- player?.load(track, playWhenReady: true)
445
+ handleLoad(track: track, queue: [track], startIndex: 0) {
446
+ onMainActor {
447
+ player?.load(track, playWhenReady: true)
448
+ }
421
449
  }
422
450
  }
423
451
  }
424
452
  }
425
453
  // If track has src, it's playable - load and auto-play
426
454
  else if track.src != nil {
427
- onMainActor {
428
- player?.load(track, playWhenReady: true)
455
+ handleLoad(track: track, queue: [track], startIndex: 0) {
456
+ onMainActor {
457
+ player?.load(track, playWhenReady: true)
458
+ }
429
459
  }
430
460
  }
431
461
  // If track has url, it's browsable - navigate to it