react-native-audio-browser 0.2.0-next.15 → 0.2.0-next.17

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 (38) hide show
  1. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +5 -1
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +3 -0
  3. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +4 -0
  4. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +1 -0
  5. package/android/src/main/java/com/audiobrowser/model/IcyTitle.kt +24 -0
  6. package/android/src/main/java/com/audiobrowser/model/PlaybackMetadata.kt +10 -6
  7. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +49 -93
  8. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -0
  9. package/android/src/main/java/com/audiobrowser/player/PlayerListener.kt +1 -1
  10. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +1 -0
  11. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +3 -0
  12. package/android/src/test/java/com/audiobrowser/model/IcyTitleTest.kt +49 -0
  13. package/ios/Browser/BrowserManager.swift +1 -0
  14. package/ios/Browser/JsonModels.swift +11 -0
  15. package/ios/CarPlay/CarPlayListItemFactory.swift +1 -1
  16. package/ios/Extension/ResolvedTrack+Copying.swift +2 -0
  17. package/ios/Extension/String+IcyCharset.swift +21 -0
  18. package/ios/Extension/TimedMetadata+AVFoundation.swift +6 -4
  19. package/ios/Extension/Track+Copying.swift +2 -0
  20. package/ios/HybridAudioBrowser.swift +1 -1
  21. package/ios/Model/NitroTypeStubs.swift +2 -0
  22. package/ios/Tests/PlaybackStateStoreTests.swift +1 -1
  23. package/ios/Tests/ResolvedTrackCopyingTests.swift +2 -0
  24. package/ios/Tests/TimedMetadataAVFoundationTests.swift +42 -0
  25. package/lib/typescript/package.json +1 -0
  26. package/lib/typescript/src/types/browser-nodes.d.ts +14 -0
  27. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  28. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +7 -3
  29. package/nitrogen/generated/android/c++/JTrack.hpp +7 -3
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ResolvedTrack.kt +6 -3
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Track.kt +6 -3
  32. package/nitrogen/generated/ios/swift/ResolvedTrack.swift +19 -1
  33. package/nitrogen/generated/ios/swift/Track.swift +19 -1
  34. package/nitrogen/generated/shared/c++/ResolvedTrack.hpp +6 -2
  35. package/nitrogen/generated/shared/c++/Track.hpp +6 -2
  36. package/package.json +2 -2
  37. package/src/types/browser-nodes.ts +15 -0
  38. package/website/guide/track.md +1 -0
@@ -224,7 +224,11 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
224
224
  private var updateOptions: PlayerUpdateOptions = PlayerUpdateOptions()
225
225
  private var mediaBrowserFuture: ListenableFuture<MediaBrowser>? = null
226
226
  private var setupOptions = PlayerSetupOptions()
227
- private var connectedService: Service? = null
227
+ // Written only on the main thread (ServiceConnection callbacks, dispose); read from main or,
228
+ // in a few places, straight from the JS thread. @Volatile gives those reads visibility — they
229
+ // must still tolerate staleness in both directions (a just-detached service, or null while
230
+ // onServiceConnected is pending). Keep writes on main; no read-modify-write.
231
+ @Volatile private var connectedService: Service? = null
228
232
 
229
233
  private val serviceBinding = ServiceBinding(context)
230
234
 
@@ -359,6 +359,7 @@ class BrowserManager {
359
359
  disabled = null,
360
360
  favorited = null,
361
361
  live = null,
362
+ icyCharset = null,
362
363
  )
363
364
  )
364
365
  }
@@ -1005,6 +1006,7 @@ class BrowserManager {
1005
1006
  disabled = null,
1006
1007
  favorited = null,
1007
1008
  live = null,
1009
+ icyCharset = null,
1008
1010
  )
1009
1011
 
1010
1012
  // Cache search results for getCachedSearchResults()
@@ -1044,6 +1046,7 @@ class BrowserManager {
1044
1046
  disabled = null,
1045
1047
  favorited = null,
1046
1048
  live = null,
1049
+ icyCharset = null,
1047
1050
  )
1048
1051
 
1049
1052
  return emptySearchResult
@@ -65,6 +65,7 @@ data class JsonResolvedTrack(
65
65
  val style: JsonElement? = null,
66
66
  val disabled: Boolean? = null,
67
67
  val live: Boolean? = null,
68
+ val icyCharset: String? = null,
68
69
  val carPlaySiriListButton: String? = null,
69
70
  )
70
71
 
@@ -87,6 +88,7 @@ data class JsonTrack(
87
88
  val style: JsonElement? = null,
88
89
  val disabled: Boolean? = null,
89
90
  val live: Boolean? = null,
91
+ val icyCharset: String? = null,
90
92
  )
91
93
 
92
94
  /**
@@ -179,6 +181,7 @@ fun JsonResolvedTrack.toNitro(): ResolvedTrack {
179
181
  disabled = disabled,
180
182
  favorited = null,
181
183
  live = live,
184
+ icyCharset = icyCharset,
182
185
  )
183
186
  }
184
187
 
@@ -202,5 +205,6 @@ fun JsonTrack.toNitro(): Track {
202
205
  disabled = disabled,
203
206
  favorited = null,
204
207
  live = live,
208
+ icyCharset = icyCharset,
205
209
  )
206
210
  }
@@ -44,4 +44,5 @@ fun ResolvedTrack.toTrack(): Track =
44
44
  disabled = disabled,
45
45
  favorited = favorited,
46
46
  live = live,
47
+ icyCharset = icyCharset,
47
48
  )
@@ -0,0 +1,24 @@
1
+ package com.audiobrowser.model
2
+
3
+ import java.nio.charset.Charset
4
+
5
+ /** Re-decodes an ICY `StreamTitle` with the charset the track declares. */
6
+ internal object IcyTitle {
7
+ /**
8
+ * [title] re-decoded through ISO-8859-1 with [charset] when it is Latin-1 shaped: every char at
9
+ * or below U+00FF, at least one at or above U+0080. That shape is what media3's ISO-8859-1
10
+ * fallback yields for bytes that are not UTF-8, and also what a station that UTF-8-encoded its
11
+ * legacy bytes yields; ISO-8859-1 is lossless, so both re-decode. Returns [title] as is when it
12
+ * has another shape, is pure ASCII, or [charset] is not one the runtime can decode with.
13
+ */
14
+ fun redecode(title: String, charset: String): String {
15
+ var high = false
16
+ for (c in title) {
17
+ if (c.code > 0xFF) return title
18
+ if (c.code >= 0x80) high = true
19
+ }
20
+ if (!high) return title
21
+ val cs = runCatching { Charset.forName(charset) }.getOrNull() ?: return title
22
+ return String(title.toByteArray(Charsets.ISO_8859_1), cs)
23
+ }
24
+ }
@@ -15,11 +15,12 @@ data class PlaybackMetadata(
15
15
  companion object {
16
16
  /**
17
17
  * Creates PlaybackMetadata from raw Metadata entries. Handles ICY streams specially, uses
18
- * Media3's unified approach for everything else.
18
+ * Media3's unified approach for everything else. [icyCharset] is the track's declared charset
19
+ * for ICY titles media3 decoded as Latin-1; it never touches other metadata.
19
20
  */
20
- fun from(metadata: Metadata): PlaybackMetadata? {
21
+ fun from(metadata: Metadata, icyCharset: String? = null): PlaybackMetadata? {
21
22
  // Try ICY first (streaming-specific)
22
- fromIcy(metadata)?.let {
23
+ fromIcy(metadata, icyCharset)?.let {
23
24
  return it
24
25
  }
25
26
 
@@ -32,12 +33,15 @@ data class PlaybackMetadata(
32
33
  }
33
34
 
34
35
  /** Shoutcast / Icecast metadata (ICY protocol) https://cast.readme.io/docs/icy */
35
- private fun fromIcy(metadata: Metadata): PlaybackMetadata? {
36
+ private fun fromIcy(metadata: Metadata, icyCharset: String?): PlaybackMetadata? {
36
37
  for (i in 0 until metadata.length()) {
37
38
  when (val entry = metadata[i]) {
38
39
  is IcyInfo -> {
39
- if (entry.title != null) {
40
- return PlaybackMetadata(title = entry.title)
40
+ val title = entry.title
41
+ if (title != null) {
42
+ return PlaybackMetadata(
43
+ title = icyCharset?.let { IcyTitle.redecode(title, it) } ?: title
44
+ )
41
45
  }
42
46
  }
43
47
  }
@@ -45,8 +45,12 @@ import kotlinx.coroutines.withContext
45
45
  import timber.log.Timber
46
46
 
47
47
  /**
48
- * MediaLibrarySession callback that handles all media session interactions. All logic is handled
49
- * directly by the AudioBrowser.
48
+ * Everything the MediaLibrarySession serves except transport: browse, search, item lookup, queue
49
+ * building, playback resumption, and the favorite and rating commands. Transport does not arrive
50
+ * here — it reaches the player through [InterceptingPlayer], and Assistant's play-from-search
51
+ * arrives as an intent handled in the service. The callbacks that answer through [scope] run on IO,
52
+ * so anything touching the ExoPlayer instance hops back to [Dispatchers.Main]; the rest answer
53
+ * synchronously on the Media3 application thread.
50
54
  */
51
55
  class MediaSessionCallback(private val player: Player) :
52
56
  MediaLibraryService.MediaLibrarySession.Callback {
@@ -69,14 +73,10 @@ class MediaSessionCallback(private val player: Player) :
69
73
  private set
70
74
 
71
75
  init {
72
- // Observe network state changes and notify subscribers
73
76
  player.networkMonitor.observeOnline(scope) { _ -> notifySubscribedChildrenChanged() }
74
77
  }
75
78
 
76
- /**
77
- * Apply pagination to a list of items. If pageSize is 0 or MAX_VALUE (Android Auto default),
78
- * returns the full list.
79
- */
79
+ /** Media3's legacy browse bridge asks for a whole list with pageSize [Int.MAX_VALUE]. */
80
80
  private fun <T> List<T>.paginate(page: Int, pageSize: Int): List<T> {
81
81
  return if (pageSize in 1 until Int.MAX_VALUE) {
82
82
  this.drop(page * pageSize).take(pageSize)
@@ -85,7 +85,6 @@ class MediaSessionCallback(private val player: Player) :
85
85
  }
86
86
  }
87
87
 
88
- /** Creates an offline error MediaItem. */
89
88
  private fun createOfflineMediaItem(): MediaItem =
90
89
  createErrorMediaItem(
91
90
  mediaId = BrowserPathHelper.OFFLINE_PATH,
@@ -99,6 +98,12 @@ class MediaSessionCallback(private val player: Player) :
99
98
  * Auto / AAOS browse list. Rendered as a greyed-out, non-interactive tile, which is the only
100
99
  * side-effect-free way to communicate a browse failure to legacy controllers (Media3 drops
101
100
  * [LibraryResult.ofError] on the legacy browse bridge, leaving an empty "No items" screen).
101
+ *
102
+ * Tiles are the only in-browse error signal that exists. Verified on a head unit (2026-06): the
103
+ * Android Auto browse list renders no error text for a transient [SessionError], for sticky
104
+ * non-fatal replication, or for fatal replication (setLibraryErrorReplicationMode). Fatal
105
+ * replication does surface the message on the playback screen, but at the cost of presenting the
106
+ * session as STATE_ERROR with no actions, hiding the now-playing item and transport controls.
102
107
  */
103
108
  private fun createErrorMediaItem(mediaId: String, title: String, subtitle: String): MediaItem =
104
109
  MediaItem.Builder()
@@ -107,23 +112,15 @@ class MediaSessionCallback(private val player: Player) :
107
112
  MediaMetadata.Builder()
108
113
  .setTitle(title)
109
114
  .setSubtitle(subtitle)
110
- // Non-browsable, non-playable. Android Auto ignores these flags and still drills into a
111
- // tapped tile, but onGetChildren returns an empty list for the sentinel paths so it's a
112
- // harmless "No Items" dead-end rather than an endless stack of error pages.
115
+ // Android Auto ignores these flags and drills into a tapped tile anyway; onGetChildren
116
+ // returns an empty list for the sentinel paths, so it dead-ends at "No Items" rather
117
+ // than stacking error pages.
113
118
  .setIsBrowsable(false)
114
119
  .setIsPlayable(false)
115
120
  .build()
116
121
  )
117
122
  .build()
118
123
 
119
- /**
120
- * Builds the gate tile from a per-request chrome: while a gate is active, tabs stay visible but a
121
- * gated browse/search level serves this single tile (see the gate checks in [onGetChildren] /
122
- * [onGetSearchResult]). Same shape as the error tiles — non-browsable, non-playable — and
123
- * deliberately NOT accompanied by a SessionError: a gate is deliberate app state, not a failure.
124
- * The message renders as the tile's subtitle (newlines collapse to spaces — list rows are
125
- * single-line).
126
- */
127
124
  /**
128
125
  * Wraps a raw external-search query string into the structured [SearchParams] the gate request
129
126
  * carries. The car search surfaces only give a free-text query, so the other fields stay null —
@@ -141,6 +138,14 @@ class MediaSessionCallback(private val player: Player) :
141
138
  reference = MediaReference.UNKNOWN,
142
139
  )
143
140
 
141
+ /**
142
+ * Builds the gate tile from a per-request chrome: while a gate is active, tabs stay visible but a
143
+ * gated browse/search level serves this single tile (see the gate checks in [onGetChildren] /
144
+ * [onGetSearchResult]). Same shape as the error tiles — non-browsable, non-playable — and
145
+ * deliberately NOT accompanied by a SessionError: a gate is deliberate app state, not a failure.
146
+ * The message renders as the tile's subtitle (newlines collapse to spaces — list rows are
147
+ * single-line).
148
+ */
144
149
  private fun createGateMediaItem(gate: Gate): MediaItem =
145
150
  createErrorMediaItem(
146
151
  mediaId = BrowserPathHelper.GATE_PATH,
@@ -150,7 +155,7 @@ class MediaSessionCallback(private val player: Player) :
150
155
 
151
156
  /**
152
157
  * Builds a generic "something went wrong" error tile for a browse failure. The true offline case
153
- * is handled separately by the [networkMonitor] guard in [onGetChildren]; anything reaching the
158
+ * is handled separately by the networkMonitor guard in [onGetChildren]; anything reaching the
154
159
  * catch block is an online-but-failed request (e.g. server down, bad status), so it must NOT be
155
160
  * labelled "no internet connection".
156
161
  */
@@ -170,7 +175,6 @@ class MediaSessionCallback(private val player: Player) :
170
175
  forwardJumpInterval: Double,
171
176
  backwardJumpInterval: Double,
172
177
  ) {
173
- // Store as MediaLibrarySession for notifyChildrenChanged support
174
178
  this.mediaLibrarySession = mediaSession as? MediaLibraryService.MediaLibrarySession
175
179
  commandManager.updateMediaSession(
176
180
  mediaSession,
@@ -196,9 +200,8 @@ class MediaSessionCallback(private val player: Player) :
196
200
  command: SessionCommand,
197
201
  args: Bundle,
198
202
  ): ListenableFuture<SessionResult> {
199
- // Handle favorite button tap. Report an honest result: a success here makes
200
- // the controller flip its heart optimistically, so a no-op (no current
201
- // track) must say INVALID_STATE instead of success-then-revert.
203
+ // A success here makes the controller flip its heart optimistically, so a no-op (no current
204
+ // track) must report INVALID_STATE instead of success-then-revert.
202
205
  if (command.customAction == MediaSessionCommandManager.CUSTOM_ACTION_FAVORITE) {
203
206
  val applied = player.toggleActiveTrackFavorited()
204
207
  Timber.d("Favorite button tapped - toggle applied=$applied")
@@ -241,13 +244,6 @@ class MediaSessionCallback(private val player: Player) :
241
244
  browser: MediaSession.ControllerInfo,
242
245
  params: MediaLibraryService.LibraryParams?,
243
246
  ): ListenableFuture<LibraryResult<MediaItem>> {
244
- // Extract artwork size hint from root hints (e.g., from Android Auto)
245
- // TODO: Also consider these other root hints in the future:
246
- // - KEY_ROOT_HINT_MEDIA_HOST_VERSION
247
- // - KEY_ROOT_HINT_MEDIA_SESSION_API
248
- // - BROWSER_ROOT_HINTS_KEY_CUSTOM_BROWSER_ACTION_LIMIT
249
- // - BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT
250
- // - KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED
251
247
  params?.extras?.getInt(MediaConstants.EXTRAS_KEY_MEDIA_ART_SIZE_PIXELS, 0)?.let { size ->
252
248
  if (size > 0) {
253
249
  artworkSizeHintPixels = size
@@ -290,7 +286,6 @@ class MediaSessionCallback(private val player: Player) :
290
286
  "onGetChildren: {parentId: $parentId, page: $page, pageSize: $pageSize, isSpecialPath: ${BrowserPathHelper.isSpecialPath(parentId)} }"
291
287
  )
292
288
  return scope.future {
293
- // Wait for browser to be registered if it's not available yet
294
289
  val audioBrowser =
295
290
  player.awaitBrowser().also { Timber.d("Browser ready, proceeding with onGetChildren") }
296
291
  val browserManager = audioBrowser.browserManager
@@ -330,7 +325,6 @@ class MediaSessionCallback(private val player: Player) :
330
325
  return@future LibraryResult.ofItemList(ImmutableList.of<MediaItem>(), params)
331
326
  }
332
327
 
333
- // Show offline error when offline:
334
328
  if (
335
329
  !player.networkMonitor.isOnline.value && browserManager.config.androidControllerOfflineError
336
330
  ) {
@@ -342,10 +336,8 @@ class MediaSessionCallback(private val player: Player) :
342
336
  try {
343
337
  val children =
344
338
  if (parentId == BrowserPathHelper.ROOT_PATH) {
345
- // Return tabs as root children (limited to 4 for automotive platform compatibility)
346
- // TODO: Check what Android Auto does with empty tabs list - may need to return error?
347
- // Disabled tabs hide (Track.disabled) — filtered before the cap so
348
- // an unavailable tab never costs an available one its slot.
339
+ // Disabled tabs hide (Track.disabled) — filtered before the four-tab cap so an
340
+ // unavailable tab never costs an available one its slot.
349
341
  val tabs = browserManager.queryTabs().filterNot { it.disabled == true }
350
342
  if (tabs.size > 4) {
351
343
  Timber.w(
@@ -354,9 +346,7 @@ class MediaSessionCallback(private val player: Player) :
354
346
  }
355
347
  toFlatMediaItems(tabs.take(4))
356
348
  } else {
357
- // Resolve the specific path and flatten its sections to MediaItems
358
- // (paths are already set to contextual paths). Section styles come
359
- // page-folded (`section ?? page`, ADR 0011).
349
+ // Section styles arrive page-folded (`section ?? page`, ADR 0011).
360
350
  val resolvedTrack = browserManager.resolve(parentId)
361
351
  val sections =
362
352
  resolvedTrack.styleResolvedSections()
@@ -371,16 +361,8 @@ class MediaSessionCallback(private val player: Player) :
371
361
  // error tile. awaitBrowser's TimeoutCancellationException IS a real failure, keep that.
372
362
  if (e is CancellationException && e !is TimeoutCancellationException) throw e
373
363
  Timber.e(e, "Error getting children for parentId: $parentId")
374
- // Surface an error tile instead of a bare ofError(): Media3 drops the error message on the
375
- // legacy browse bridge, which would otherwise leave an empty "No items" screen in Android
376
- // Auto. See https://github.com/androidx/media/issues/2901
377
- //
378
- // Verified on a head unit (2026-06): the AA browse list NEVER renders error text — not for
379
- // transient sendError, sticky non-fatal replication, or fatal replication
380
- // (setLibraryErrorReplicationMode). Fatal replication does show the message on the
381
- // playback screen, but presents the session as STATE_ERROR with no actions, hiding the
382
- // now-playing item and transport controls — not worth it. Tiles remain the only in-browse
383
- // signal we control.
364
+ // A tile rather than a bare ofError() — see createErrorMediaItem and
365
+ // https://github.com/androidx/media/issues/2901
384
366
  sendBrowseError(session, browser, offline = false)
385
367
  LibraryResult.ofItemList(ImmutableList.of(createBrowseErrorMediaItem()), params)
386
368
  }
@@ -393,8 +375,8 @@ class MediaSessionCallback(private val player: Player) :
393
375
  * Media3 transiently attaches the error code/message to the platform session's playback state
394
376
  * without entering STATE_ERROR.
395
377
  *
396
- * NOTE: current Android Auto renders NOTHING for this (verified on a head unit — see
397
- * androidx/media#2901; the transient state is cleared microseconds after being set). It is kept
378
+ * NOTE: current Android Auto renders nothing for this — its transient state is cleared
379
+ * microseconds after being set (see [createErrorMediaItem], androidx/media#2901). It is kept
398
380
  * because it is the correct Media3-API error signal, costs nothing, and becomes user-visible if
399
381
  * Android Auto moves to consuming SessionError via the Media3 controller API.
400
382
  *
@@ -462,13 +444,12 @@ class MediaSessionCallback(private val player: Player) :
462
444
  ): ListenableFuture<LibraryResult<MediaItem>> {
463
445
  Timber.Forest.d("onGetItem: ${browser.packageName}, mediaId = $mediaId")
464
446
  return scope.future {
465
- // Handle special paths first (these don't need browser)
447
+ // Offline, error and root answer ahead of awaitBrowser: they need no browser. The gate
448
+ // sentinel below still does, for gateDecision.
466
449
  if (mediaId == BrowserPathHelper.OFFLINE_PATH) {
467
450
  return@future LibraryResult.ofItem(createOfflineMediaItem(), null)
468
451
  }
469
452
 
470
- // Return the error tile as a non-browsable item so tapping it can't re-browse into a
471
- // failing path (which would push another error page onto the stack).
472
453
  if (mediaId == BrowserPathHelper.ERROR_PATH) {
473
454
  return@future LibraryResult.ofItem(createBrowseErrorMediaItem(), null)
474
455
  }
@@ -503,7 +484,6 @@ class MediaSessionCallback(private val player: Player) :
503
484
  )
504
485
  }
505
486
 
506
- // Wait for browser to be registered if it's not available yet
507
487
  val browserManager = player.awaitBrowser().browserManager
508
488
 
509
489
  // Serve tracks from the track cache first (keyed by path and src). Besides avoiding an HTTP
@@ -578,9 +558,6 @@ class MediaSessionCallback(private val player: Player) :
578
558
  }
579
559
 
580
560
  /**
581
- * Notifies all subscribed controllers to refresh their content. Called internally when network
582
- * state changes to refresh all subscribed paths.
583
- *
584
561
  * Safe to call from any thread: snapshots the subscribed paths and dispatches the session calls
585
562
  * to the main (Media3 application) thread.
586
563
  */
@@ -593,10 +570,8 @@ class MediaSessionCallback(private val player: Player) :
593
570
  }
594
571
 
595
572
  /**
596
- * Notifies external controllers that content at the given path has changed. Controllers
597
- * subscribed to this path will refresh their UI. Safe to call from any thread.
598
- *
599
- * @param path The path where content has changed
573
+ * Notifies external controllers that content at the given path has changed, so controllers
574
+ * subscribed to it refresh their UI. Safe to call from any thread.
600
575
  */
601
576
  fun notifyContentChanged(path: String) {
602
577
  Timber.d("Notifying content changed for path: $path")
@@ -614,10 +589,6 @@ class MediaSessionCallback(private val player: Player) :
614
589
  notifySubscribedChildrenChanged()
615
590
  }
616
591
 
617
- /**
618
- * Called when the browser becomes available after a cold start. Notifies all subscribed
619
- * controllers to refresh their content.
620
- */
621
592
  fun notifyBrowserReady() {
622
593
  Timber.d("Browser ready - notifying all subscribed paths")
623
594
  notifySubscribedChildrenChanged()
@@ -636,8 +607,6 @@ class MediaSessionCallback(private val player: Player) :
636
607
  ): ListenableFuture<LibraryResult<Void>> {
637
608
  Timber.d("onSearch: ${browser.packageName}, query = $query")
638
609
  return scope.future {
639
- // Wait for browser registration like onGetChildren does, so a cold-start voice search
640
- // doesn't fail before JS has configured the browser.
641
610
  val audioBrowser =
642
611
  try {
643
612
  player.awaitBrowser()
@@ -659,14 +628,14 @@ class MediaSessionCallback(private val player: Player) :
659
628
  return@future LibraryResult.ofVoid()
660
629
  }
661
630
 
662
- // Check if search is configured
663
631
  if (!browserManager.config.hasSearch) {
664
632
  Timber.w("Search requested but no search source configured")
665
633
  return@future LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED)
666
634
  }
667
635
 
668
636
  try {
669
- // Execute search (automatically caches results at /__search?q=query)
637
+ // Caches the result set under the query, which onGetSearchResult reads back through
638
+ // getCachedSearchResults.
670
639
  val searchResults = browserManager.search(query)
671
640
  // Count what onGetSearchResult will actually serve: disabled tracks
672
641
  // hide on Android Auto, so they must not inflate the announced count.
@@ -677,7 +646,6 @@ class MediaSessionCallback(private val player: Player) :
677
646
 
678
647
  Timber.d("Search completed: $resultCount results for query '$query'")
679
648
 
680
- // Notify Media3 of search results
681
649
  session.notifySearchResultChanged(browser, query, resultCount, params)
682
650
 
683
651
  LibraryResult.ofVoid()
@@ -724,10 +692,7 @@ class MediaSessionCallback(private val player: Player) :
724
692
  }
725
693
 
726
694
  try {
727
- // Get cached search results from BrowserManager
728
695
  browserManager.getCachedSearchResults(query)?.let { tracks ->
729
- // Through the shared browse conversion, so search results get the
730
- // same artwork routing as browse rows.
731
696
  val mediaItems = toFlatMediaItems(tracks.toList())
732
697
 
733
698
  val paginatedItems = mediaItems.paginate(page, pageSize)
@@ -766,8 +731,9 @@ class MediaSessionCallback(private val player: Player) :
766
731
  return scope.future {
767
732
  val audioBrowser = player.awaitBrowser()
768
733
 
769
- // Helper: returns the current player state unchanged so Media3 doesn't modify playback.
770
- // ExoPlayer is main-thread confined; this future runs on IO, so hop to Main for the reads.
734
+ // The "leave playback alone" answer for handleTrackLoad: returning the current state
735
+ // unchanged is how Media3 is told to do nothing. ExoPlayer is main-thread confined and this
736
+ // future runs on IO, so the reads hop to Main.
771
737
  suspend fun currentPlayerState(): MediaSession.MediaItemsWithStartPosition =
772
738
  withContext(Dispatchers.Main) {
773
739
  val currentItems = player.tracks.map { TrackFactory.toMedia3(it) }
@@ -788,14 +754,12 @@ class MediaSessionCallback(private val player: Player) :
788
754
  ?.takeIf { it.requestMetadata.searchQuery == null }
789
755
  ?.let { browserManager.contextualPathFor(it.mediaId) }
790
756
 
791
- // Check if this is a single item from the current queue source
792
757
  if (singleContextualPath != null) {
793
758
  val parentPath = BrowserPathHelper.stripTrackId(singleContextualPath)
794
759
  val trackId = BrowserPathHelper.extractTrackId(singleContextualPath)
795
760
 
796
- // Check if queue already came from this parent path - just skip to the
797
- // tapped surface (exact path first, identity for index-less paths —
798
- // see indexOfTappedTrack)
761
+ // A tap inside the queue's own source list skips rather than rebuilds (exact path first,
762
+ // identity for index-less paths — see indexOfTappedTrack).
799
763
  if (trackId != null && parentPath == player.queueSourcePath) {
800
764
  val queueTracks = withContext(Dispatchers.Main) { player.tracks }
801
765
  val index = queueTracks.indexOfTappedTrack(singleContextualPath, trackId)
@@ -809,7 +773,6 @@ class MediaSessionCallback(private val player: Player) :
809
773
  index.toDouble(),
810
774
  ::currentPlayerState,
811
775
  ) {
812
- // Return the existing queue items with the new start index
813
776
  val existingItems = queueTracks.map { TrackFactory.toMedia3(it) }
814
777
  MediaSession.MediaItemsWithStartPosition(existingItems, index, startPositionMs)
815
778
  }
@@ -830,8 +793,8 @@ class MediaSessionCallback(private val player: Player) :
830
793
  result.startIndex.toDouble(),
831
794
  ::currentPlayerState,
832
795
  ) {
833
- // If this was a contextual path expansion, track the source path (only for default
834
- // behavior)
796
+ // Inside handleTrackLoad's default branch: a consumer that handles the load itself owns
797
+ // the queue, so queueSourcePath must not claim this path on its behalf.
835
798
  if (singleContextualPath != null) {
836
799
  val parentPath = BrowserPathHelper.stripTrackId(singleContextualPath)
837
800
  withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
@@ -849,8 +812,6 @@ class MediaSessionCallback(private val player: Player) :
849
812
  * using the browse callback. This enables seamless resumption after app restart with the same
850
813
  * player settings (repeat mode, shuffle, playback speed).
851
814
  *
852
- * @param mediaSession The media session
853
- * @param controller The controller requesting resumption
854
815
  * @param isForPlayback True if this should start playback; false if just gathering info for the
855
816
  * boot-time resumption notification (no network; local metadata only).
856
817
  * @see https://developer.android.com/media/media3/session/background-playback#resumption
@@ -881,9 +842,8 @@ class MediaSessionCallback(private val player: Player) :
881
842
  throw IllegalStateException("Persisted track is disabled")
882
843
  }
883
844
 
884
- // isForPlayback == false is the device-boot-time notification case: network may be
885
- // unavailable and we must not start playback. Return just the locally-stored track with its
886
- // already-local metadata; skip the settings restore and network queue expansion below.
845
+ // Info-gathering returns before the settings restore and the queue expansion below: both
846
+ // touch the live player or the network, and neither is permitted here.
887
847
  if (!isForPlayback) {
888
848
  Timber.d("Info-gathering resumption (boot-time); returning stored track without expansion")
889
849
  return@future MediaSession.MediaItemsWithStartPosition(
@@ -899,10 +859,8 @@ class MediaSessionCallback(private val player: Player) :
899
859
  val path = state.track.path
900
860
  Timber.d("Resuming from path=$path, positionMs=${state.positionMs}")
901
861
 
902
- // Wait for browser to be available (JS needs to have configured it)
903
862
  val browserManager = player.awaitBrowser().browserManager
904
863
 
905
- // Try to expand the path into a full queue
906
864
  val expanded = path?.let { browserManager.expandQueueFromContextualPath(it) }
907
865
 
908
866
  if (expanded != null) {
@@ -911,7 +869,6 @@ class MediaSessionCallback(private val player: Player) :
911
869
  "Restored ${tracks.size} tracks, starting at index $selectedIndex at ${state.positionMs}ms"
912
870
  )
913
871
 
914
- // Track the source path if this was a contextual path expansion
915
872
  if (BrowserPathHelper.isContextual(path)) {
916
873
  val parentPath = BrowserPathHelper.stripTrackId(path)
917
874
  withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
@@ -923,7 +880,6 @@ class MediaSessionCallback(private val player: Player) :
923
880
  state.positionMs,
924
881
  )
925
882
  } else {
926
- // Fallback: play just the stored track
927
883
  Timber.d("Queue expansion failed, using stored track: ${state.track.title}")
928
884
  MediaSession.MediaItemsWithStartPosition(
929
885
  ImmutableList.of(TrackFactory.toMedia3(state.track)),
@@ -233,6 +233,7 @@ class PlaybackStateStore(private val player: Player) {
233
233
  put("disabled", track.disabled)
234
234
  put("favorited", track.favorited)
235
235
  put("live", track.live)
236
+ put("icyCharset", track.icyCharset)
236
237
  }
237
238
  .toString()
238
239
 
@@ -281,6 +282,7 @@ class PlaybackStateStore(private val player: Player) {
281
282
  if (obj.has("favorited") && !obj.isNull("favorited")) obj.getBoolean("favorited")
282
283
  else null,
283
284
  live = if (obj.has("live") && !obj.isNull("live")) obj.getBoolean("live") else null,
285
+ icyCharset = obj.optString("icyCharset").takeIf { it.isNotEmpty() },
284
286
  // Not persisted: on resumption the contextual path is re-browsed
285
287
  // (expandQueueFromContextualPath), which re-parses each track's request
286
288
  // from the API's current response and re-caches it.
@@ -59,7 +59,7 @@ class PlayerListener(private val player: Player) : MediaPlayer.Listener {
59
59
  }
60
60
 
61
61
  // Extract and emit timed metadata (ICY, ID3, etc.)
62
- PlaybackMetadata.from(metadata)?.let {
62
+ PlaybackMetadata.from(metadata, player.currentTrack?.icyCharset)?.let {
63
63
  val timed = it.toNitro()
64
64
  player.callbacks?.onTimedMetadata(timed)
65
65
  player.nowPlaying.onTimedMetadataReceived(timed)
@@ -64,6 +64,7 @@ object TrackFactory {
64
64
  disabled = null,
65
65
  favorited = null,
66
66
  live = null,
67
+ icyCharset = null,
67
68
  )
68
69
 
69
70
  fun toMedia3(tracks: Array<Track>): List<MediaItem> {
@@ -55,6 +55,7 @@ object TestFixtures {
55
55
  disabled = disabled,
56
56
  favorited = favorited,
57
57
  live = null,
58
+ icyCharset = null,
58
59
  )
59
60
 
60
61
  /** A page [Section] — plain list by default. */
@@ -136,6 +137,7 @@ object TestFixtures {
136
137
  disabled = null,
137
138
  favorited = null,
138
139
  live = null,
140
+ icyCharset = null,
139
141
  )
140
142
 
141
143
  /** A container [ResolvedTrack] — what a browse route hands back for a path. */
@@ -168,6 +170,7 @@ object TestFixtures {
168
170
  disabled = null,
169
171
  favorited = null,
170
172
  live = null,
173
+ icyCharset = null,
171
174
  )
172
175
 
173
176
  /**
@@ -0,0 +1,49 @@
1
+ package com.audiobrowser.model
2
+
3
+ import java.nio.charset.Charset
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Test
6
+
7
+ class IcyTitleTest {
8
+ /** What media3's ISO-8859-1 fallback yields for [text] sent as [charset]. */
9
+ private fun latin1Shaped(text: String, charset: String) =
10
+ String(text.toByteArray(Charset.forName(charset)), Charsets.ISO_8859_1)
11
+
12
+ @Test
13
+ fun `re-decodes a legacy code page title`() {
14
+ val title = latin1Shaped("Артист - Песня", "windows-1251")
15
+ assertEquals("Артист - Песня", IcyTitle.redecode(title, "windows-1251"))
16
+ }
17
+
18
+ @Test
19
+ fun `re-decodes thai`() {
20
+ val title = latin1Shaped("ศิลปิน - เพลง", "windows-874")
21
+ assertEquals("ศิลปิน - เพลง", IcyTitle.redecode(title, "windows-874"))
22
+ }
23
+
24
+ @Test
25
+ fun `re-decodes a title the station utf-8 encoded from legacy bytes`() {
26
+ // media3 decodes the valid UTF-8 to the same Latin-1-shaped string the fallback would give.
27
+ val doubleEncoded = latin1Shaped("Artist - เพลง", "windows-874").toByteArray(Charsets.UTF_8)
28
+ assertEquals(
29
+ "Artist - เพลง",
30
+ IcyTitle.redecode(String(doubleEncoded, Charsets.UTF_8), "windows-874"),
31
+ )
32
+ }
33
+
34
+ @Test
35
+ fun `leaves a title already in another script`() {
36
+ assertEquals("Песня", IcyTitle.redecode("Песня", "windows-1251"))
37
+ }
38
+
39
+ @Test
40
+ fun `leaves ascii`() {
41
+ assertEquals("Plain Title", IcyTitle.redecode("Plain Title", "windows-1251"))
42
+ }
43
+
44
+ @Test
45
+ fun `ignores a charset the runtime cannot decode with`() {
46
+ val title = latin1Shaped("Песня", "windows-1251")
47
+ assertEquals(title, IcyTitle.redecode(title, "no-such-charset"))
48
+ }
49
+ }
@@ -738,6 +738,7 @@ final class BrowserManager {
738
738
  disabled: nil,
739
739
  favorited: nil,
740
740
  live: nil,
741
+ icyCharset: nil,
741
742
  )
742
743
  }
743
744