@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.
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +34 -9
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +57 -17
- package/android/src/main/java/com/audiobrowser/player/TransformingDataSource.kt +33 -11
- package/ios/Browser/BrowserManager.swift +2 -17
- package/ios/CarPlay/CarPlayController.swift +75 -27
- package/ios/HybridAudioBrowser.swift +42 -12
- package/ios/TrackPlayer.swift +39 -37
- package/lib/commonjs/features/remoteControls.js +24 -1
- package/lib/commonjs/features/remoteControls.js.map +1 -1
- package/lib/commonjs/web/NativeAudioBrowser.js +34 -5
- package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
- package/lib/module/features/remoteControls.js +22 -0
- package/lib/module/features/remoteControls.js.map +1 -1
- package/lib/module/web/NativeAudioBrowser.js +34 -5
- package/lib/module/web/NativeAudioBrowser.js.map +1 -1
- package/lib/typescript/src/features/remoteControls.d.ts +26 -0
- package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
- package/lib/typescript/src/specs/audio-browser.nitro.d.ts +3 -1
- package/lib/typescript/src/specs/audio-browser.nitro.d.ts.map +1 -1
- package/lib/typescript/src/web/NativeAudioBrowser.d.ts +11 -1
- package/lib/typescript/src/web/NativeAudioBrowser.d.ts.map +1 -1
- package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_RemoteLoadEvent.hpp +89 -0
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +39 -0
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +4 -0
- package/nitrogen/generated/android/c++/JRemoteLoadEvent.hpp +94 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_void_RemoteLoadEvent.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +28 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteLoadEvent.kt +44 -0
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +40 -0
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_RemoteLoadEvent.swift +47 -0
- package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +51 -0
- package/nitrogen/generated/ios/swift/RemoteLoadEvent.swift +70 -0
- package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +4 -0
- package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +7 -0
- package/nitrogen/generated/shared/c++/RemoteLoadEvent.hpp +85 -0
- package/package.json +1 -1
- package/src/features/remoteControls.ts +35 -0
- package/src/specs/audio-browser.nitro.ts +3 -0
- 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.
|
|
531
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
existingItems
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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 {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|