react-native-audio-browser 0.2.0-next.16 → 0.2.0-next.18

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 (27) hide show
  1. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +165 -107
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
  3. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +409 -186
  4. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
  5. package/android/src/main/res/values/strings.xml +6 -21
  6. package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
  7. package/lib/commonjs/features/browser.js +8 -0
  8. package/lib/commonjs/features/browser.js.map +1 -1
  9. package/lib/commonjs/web/browser/BrowserManager.js +6 -8
  10. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  11. package/lib/commonjs/web/browser/SearchManager.js +4 -7
  12. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  13. package/lib/module/features/browser.js +8 -0
  14. package/lib/module/features/browser.js.map +1 -1
  15. package/lib/module/web/browser/BrowserManager.js +6 -8
  16. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  17. package/lib/module/web/browser/SearchManager.js +4 -7
  18. package/lib/module/web/browser/SearchManager.js.map +1 -1
  19. package/lib/typescript/package.json +1 -0
  20. package/lib/typescript/src/features/browser.d.ts +8 -0
  21. package/lib/typescript/src/features/browser.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/features/browser.ts +8 -0
  24. package/src/web/browser/BrowserManager.ts +6 -11
  25. package/src/web/browser/SearchManager.test.ts +75 -0
  26. package/src/web/browser/SearchManager.ts +7 -10
  27. package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
@@ -93,13 +93,17 @@ import java.util.concurrent.atomic.AtomicReference
93
93
  import kotlin.coroutines.resume
94
94
  import kotlin.coroutines.resumeWithException
95
95
  import kotlin.coroutines.suspendCoroutine
96
+ import kotlin.time.Duration.Companion.seconds
96
97
  import kotlinx.coroutines.CancellationException
98
+ import kotlinx.coroutines.CoroutineScope
97
99
  import kotlinx.coroutines.Job
98
100
  import kotlinx.coroutines.MainScope
101
+ import kotlinx.coroutines.async
99
102
  import kotlinx.coroutines.cancel
100
103
  import kotlinx.coroutines.cancelChildren
101
104
  import kotlinx.coroutines.launch
102
105
  import kotlinx.coroutines.runBlocking
106
+ import kotlinx.coroutines.withTimeoutOrNull
103
107
  import timber.log.Timber
104
108
 
105
109
  @Keep
@@ -224,7 +228,11 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
224
228
  private var updateOptions: PlayerUpdateOptions = PlayerUpdateOptions()
225
229
  private var mediaBrowserFuture: ListenableFuture<MediaBrowser>? = null
226
230
  private var setupOptions = PlayerSetupOptions()
227
- private var connectedService: Service? = null
231
+ // Written only on the main thread (ServiceConnection callbacks, dispose); read from main or,
232
+ // in a few places, straight from the JS thread. @Volatile gives those reads visibility — they
233
+ // must still tolerate staleness in both directions (a just-detached service, or null while
234
+ // onServiceConnected is pending). Keep writes on main; no read-modify-write.
235
+ @Volatile private var connectedService: Service? = null
228
236
 
229
237
  private val serviceBinding = ServiceBinding(context)
230
238
 
@@ -499,121 +507,48 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
499
507
 
500
508
  override fun getFormattedNavigationError(): FormattedNavigationError? = formattedNavigationError
501
509
 
502
- /** Creates a default formatted error from a NavigationError */
503
- private fun defaultFormattedError(error: NavigationError): FormattedNavigationError {
504
- val title =
505
- when (error.code) {
506
- NavigationErrorType.CONTENT_NOT_FOUND -> "Content Not Found"
507
- NavigationErrorType.NETWORK_ERROR -> "Network Error"
508
- NavigationErrorType.HTTP_ERROR -> {
509
- // Use system-localized HTTP status text (e.g., "Not Found", "Service Unavailable")
510
- error.statusCode?.let { httpStatusText(it.toInt()) } ?: "Server Error"
511
- }
512
- NavigationErrorType.CALLBACK_ERROR -> "Error"
513
- NavigationErrorType.UNKNOWN_ERROR -> "Error"
514
- // Not a failure — a container that resolved with no children. Neutral copy. See ADR 0001.
515
- NavigationErrorType.EMPTY_CONTENT -> "Nothing here"
516
- NavigationErrorType.TIMEOUT -> "Couldn't load"
517
- }
518
- // Omit an empty message so it renders as title-only (e.g. the empty-content case).
519
- return FormattedNavigationError(title, error.message.takeIf { it.isNotEmpty() })
520
- }
521
-
522
- /** Returns localized HTTP status text for the given status code */
523
- private fun httpStatusText(statusCode: Int): String {
524
- return when (statusCode) {
525
- 400 -> "Bad Request"
526
- 401 -> "Unauthorized"
527
- 403 -> "Forbidden"
528
- 404 -> "Not Found"
529
- 405 -> "Method Not Allowed"
530
- 408 -> "Request Timeout"
531
- 429 -> "Too Many Requests"
532
- 500 -> "Internal Server Error"
533
- 502 -> "Bad Gateway"
534
- 503 -> "Service Unavailable"
535
- 504 -> "Gateway Timeout"
536
- else -> "Server Error"
510
+ /**
511
+ * Resolves a navigation error to its display form: the app's `formatNavigationError` for the
512
+ * given [path] when one is configured, else the built-in default. Shared by [setNavigationError]
513
+ * and the Android Auto empty tile so in-app and in-car copy cannot drift (ADR 0001).
514
+ */
515
+ internal suspend fun resolveFormattedError(
516
+ error: NavigationError,
517
+ path: String,
518
+ ): FormattedNavigationError {
519
+ val default = defaultFormattedError(error)
520
+ val formatter = _configuration.formatNavigationError ?: return default
521
+ return formattedOrDefault(mainScope, default) {
522
+ formatter(FormatNavigationErrorParams(error, default, path)).await()
537
523
  }
538
524
  }
539
525
 
540
- private fun setNavigationError(
541
- code: NavigationErrorType,
542
- message: String,
543
- path: String,
544
- statusCode: Double? = null,
545
- statusCodeSuccess: Boolean? = null,
546
- ) {
547
- val navError = NavigationError(code, message, statusCode, statusCodeSuccess)
526
+ private fun setNavigationError(navError: NavigationError, path: String) {
548
527
  navigationError = navError
549
528
  onNavigationError(NavigationErrorEvent(navigationError))
550
529
 
551
- // Format the error (async if using JS callback, sync for defaults)
552
- val defaultFormatted = defaultFormattedError(navError)
553
- val formatter = _configuration.formatNavigationError
554
- if (formatter != null) {
555
- mainScope.launch {
556
- try {
557
- val params = FormatNavigationErrorParams(navError, defaultFormatted, path)
558
- val customFormatted = formatter(params).await()
559
- formattedNavigationError = customFormatted ?: defaultFormatted
560
- } catch (e: Exception) {
561
- formattedNavigationError = defaultFormatted
562
- }
563
- onFormattedNavigationError(formattedNavigationError)
564
- }
565
- } else {
566
- formattedNavigationError = defaultFormatted
567
- onFormattedNavigationError(formattedNavigationError)
530
+ // No formatter means no hop, and the field and event land in this turn: a same-turn
531
+ // getFormattedNavigationError, or a clearNavigationError right after, must not race a
532
+ // launch that has yet to run.
533
+ if (_configuration.formatNavigationError == null) {
534
+ val default = defaultFormattedError(navError)
535
+ formattedNavigationError = default
536
+ onFormattedNavigationError(default)
537
+ return
538
+ }
539
+
540
+ mainScope.launch {
541
+ val formatted = resolveFormattedError(navError, path)
542
+ formattedNavigationError = formatted
543
+ onFormattedNavigationError(formatted)
568
544
  }
569
545
  }
570
546
 
571
- /** Maps common browser exceptions to navigation errors */
547
+ /** Raises the navigation error [e] maps to, for a failed navigation to [path]. */
572
548
  private fun handleBrowserException(e: Exception, path: String, logContext: String) {
573
- when (e) {
574
- is HttpStatusException -> {
575
- Timber.e(e, "HTTP error $logContext")
576
- setNavigationError(
577
- NavigationErrorType.HTTP_ERROR,
578
- e.message ?: "Server error",
579
- path,
580
- e.statusCode.toDouble(),
581
- e.statusCode in 200..299,
582
- )
583
- }
584
- is NetworkException -> {
585
- Timber.e(e, "Network error $logContext")
586
- setNavigationError(
587
- NavigationErrorType.NETWORK_ERROR,
588
- e.message ?: "Network request failed",
589
- path,
590
- )
591
- }
592
- is ContentNotFoundException -> {
593
- Timber.e(e, "Content not found $logContext")
594
- setNavigationError(
595
- NavigationErrorType.CONTENT_NOT_FOUND,
596
- e.message ?: "Content not found",
597
- path,
598
- )
599
- }
600
- is CallbackException -> {
601
- Timber.e(e, "Callback error $logContext")
602
- setNavigationError(
603
- NavigationErrorType.CALLBACK_ERROR,
604
- e.message ?: "An error occurred",
605
- path,
606
- )
607
- }
608
- else -> {
609
- Timber.e(e, "Unexpected error $logContext")
610
- setNavigationError(
611
- NavigationErrorType.UNKNOWN_ERROR,
612
- e.message ?: "An unexpected error occurred",
613
- path,
614
- )
615
- }
616
- }
549
+ val navError = navigationErrorFor(e)
550
+ Timber.e(e, "${navError.code} $logContext")
551
+ setNavigationError(navError, path)
617
552
  }
618
553
 
619
554
  private fun clearNavigationError() {
@@ -765,7 +700,18 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
765
700
  override fun search(query: String): Promise<Array<Track>> {
766
701
  return Promise.async(mainScope) {
767
702
  Timber.d("Searching for: $query")
768
- val searchResults = browserManager.search(query)
703
+ // Rejects with the error the browse surface would raise, but leaves the navigation error
704
+ // itself unset: a search is not a navigation, and its failure must not replace the
705
+ // displayed page's state (mirrors HybridAudioBrowser.search).
706
+ val searchResults =
707
+ try {
708
+ browserManager.search(query)
709
+ } catch (e: CancellationException) {
710
+ throw e
711
+ } catch (e: Exception) {
712
+ Timber.e(e, "Search failed for query: $query")
713
+ throw NavigationErrorException(navigationErrorFor(e))
714
+ }
769
715
  // Flattened, not `children`: a resolved page carries its rows in
770
716
  // `sections` (ADR 0010) and search builds exactly one untitled section,
771
717
  // so `children` is always null here and this returned nothing at all.
@@ -1622,3 +1568,115 @@ private fun NativeRouteEntry.strippingJSCallbacks() =
1622
1568
  media = media?.strippingJSCallbacks(),
1623
1569
  artwork = artwork?.strippingJSCallbacks(),
1624
1570
  )
1571
+
1572
+ /**
1573
+ * Ceiling on the app's `formatNavigationError` hop. Same budget [Player.awaitBrowser] gives the JS
1574
+ * side, the only other JS wait a browse serve already blocks on.
1575
+ */
1576
+ private val FORMATTER_TIMEOUT = 10.seconds
1577
+
1578
+ /**
1579
+ * The navigation error an exception raises. Shared by [AudioBrowser]'s failed navigations, the
1580
+ * rejection [AudioBrowser.search] hands JS, and the Android Auto browse-error tile, so one failure
1581
+ * is named one way wherever it surfaces.
1582
+ */
1583
+ internal fun navigationErrorFor(e: Exception): NavigationError =
1584
+ when (e) {
1585
+ is HttpStatusException ->
1586
+ NavigationError(
1587
+ NavigationErrorType.HTTP_ERROR,
1588
+ e.message ?: "Server error",
1589
+ e.statusCode.toDouble(),
1590
+ e.statusCode in 200..299,
1591
+ )
1592
+ is NetworkException ->
1593
+ NavigationError(
1594
+ NavigationErrorType.NETWORK_ERROR,
1595
+ e.message ?: "Network request failed",
1596
+ null,
1597
+ null,
1598
+ )
1599
+ is ContentNotFoundException ->
1600
+ NavigationError(
1601
+ NavigationErrorType.CONTENT_NOT_FOUND,
1602
+ e.message ?: "Content not found",
1603
+ null,
1604
+ null,
1605
+ )
1606
+ is CallbackException ->
1607
+ NavigationError(
1608
+ NavigationErrorType.CALLBACK_ERROR,
1609
+ e.message ?: "An error occurred",
1610
+ null,
1611
+ null,
1612
+ )
1613
+ else ->
1614
+ NavigationError(
1615
+ NavigationErrorType.UNKNOWN_ERROR,
1616
+ e.message ?: "An unexpected error occurred",
1617
+ null,
1618
+ null,
1619
+ )
1620
+ }
1621
+
1622
+ /** Carries a [NavigationError] across a rejected Promise, whose JS error keeps its message. */
1623
+ class NavigationErrorException(val error: NavigationError) : Exception(error.message)
1624
+
1625
+ /**
1626
+ * The built-in display copy for a navigation error, and the fallback when the app's is unusable.
1627
+ */
1628
+ internal fun defaultFormattedError(error: NavigationError): FormattedNavigationError {
1629
+ val title =
1630
+ when (error.code) {
1631
+ NavigationErrorType.CONTENT_NOT_FOUND -> "Content Not Found"
1632
+ NavigationErrorType.NETWORK_ERROR -> "Network Error"
1633
+ NavigationErrorType.HTTP_ERROR -> {
1634
+ // Use system-localized HTTP status text (e.g., "Not Found", "Service Unavailable")
1635
+ error.statusCode?.let { httpStatusText(it.toInt()) } ?: "Server Error"
1636
+ }
1637
+ NavigationErrorType.CALLBACK_ERROR -> "Error"
1638
+ NavigationErrorType.UNKNOWN_ERROR -> "Error"
1639
+ // Not a failure — a container that resolved with no children. Neutral copy. See ADR 0001.
1640
+ NavigationErrorType.EMPTY_CONTENT -> "Nothing here"
1641
+ NavigationErrorType.TIMEOUT -> "Couldn't load"
1642
+ }
1643
+ // Omit an empty message so it renders as title-only (e.g. the empty-content case).
1644
+ return FormattedNavigationError(title, error.message.takeIf { it.isNotEmpty() })
1645
+ }
1646
+
1647
+ /**
1648
+ * The formatter hop's fallback rule: the app's answer when it produces one, [default] when it
1649
+ * answers null, throws, or outruns [FORMATTER_TIMEOUT]. Kept off the JS edge so the rule is
1650
+ * exercisable without a Nitro Promise.
1651
+ *
1652
+ * The hop runs in [scope] and is awaited through its [kotlinx.coroutines.Deferred], because Nitro's
1653
+ * `Promise.await` parks in a plain `suspendCoroutine`: cancelling it does not unpark it. Awaiting
1654
+ * the Deferred is cancellable, so the timeout leaves the orphan to finish unobserved instead of
1655
+ * holding the caller open until JS answers — which for a browse serve would be forever.
1656
+ */
1657
+ internal suspend fun formattedOrDefault(
1658
+ scope: CoroutineScope,
1659
+ default: FormattedNavigationError,
1660
+ format: suspend () -> FormattedNavigationError?,
1661
+ ): FormattedNavigationError {
1662
+ val hop = scope.async { runCatching { format() }.getOrNull() }
1663
+ return withTimeoutOrNull(FORMATTER_TIMEOUT) { hop.await() } ?: default
1664
+ }
1665
+
1666
+ /** Returns localized HTTP status text for the given status code */
1667
+ private fun httpStatusText(statusCode: Int): String {
1668
+ return when (statusCode) {
1669
+ 400 -> "Bad Request"
1670
+ 401 -> "Unauthorized"
1671
+ 403 -> "Forbidden"
1672
+ 404 -> "Not Found"
1673
+ 405 -> "Method Not Allowed"
1674
+ 408 -> "Request Timeout"
1675
+ 429 -> "Too Many Requests"
1676
+ 500 -> "Internal Server Error"
1677
+ 502 -> "Bad Gateway"
1678
+ 503 -> "Service Unavailable"
1679
+ 504 -> "Gateway Timeout"
1680
+ else -> "Server Error"
1681
+ }
1682
+ }
@@ -877,6 +877,8 @@ class BrowserManager {
877
877
  *
878
878
  * @param query The search query string
879
879
  * @return Array of playable tracks, or null if no results or search not configured
880
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
881
+ * never "couldn't ask"
880
882
  */
881
883
  suspend fun searchPlayable(query: String): Array<Track>? {
882
884
  return searchPlayable(
@@ -900,6 +902,8 @@ class BrowserManager {
900
902
  *
901
903
  * @param params The structured search parameters
902
904
  * @return Array of playable tracks, or null if no results or search not configured
905
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
906
+ * never "couldn't ask"
903
907
  */
904
908
  suspend fun searchPlayable(params: SearchParams): Array<Track>? {
905
909
  val searchResults = search(params)
@@ -937,6 +941,8 @@ class BrowserManager {
937
941
  *
938
942
  * @param query The search query string
939
943
  * @return ResolvedTrack containing search results as children
944
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
945
+ * never "couldn't ask"
940
946
  */
941
947
  suspend fun search(query: String): ResolvedTrack {
942
948
  return search(
@@ -960,6 +966,8 @@ class BrowserManager {
960
966
  *
961
967
  * @param params The structured search parameters
962
968
  * @return ResolvedTrack containing search results as children
969
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
970
+ * never "couldn't ask"
963
971
  */
964
972
  suspend fun search(params: SearchParams): ResolvedTrack {
965
973
  Timber.d("Executing fresh search for: ${params.query} (mode=${params.mode})")
@@ -1020,36 +1028,10 @@ class BrowserManager {
1020
1028
 
1021
1029
  return searchResolvedTrack
1022
1030
  } catch (e: Exception) {
1031
+ // Propagates, like the iOS `search`: a caller that cannot tell a failed search from one
1032
+ // that found nothing renders a server error as "No results found".
1023
1033
  Timber.e(e, "Error during search for query: ${params.query}")
1024
-
1025
- // Return empty search result on error
1026
- val emptySearchResult =
1027
- ResolvedTrack(
1028
- id = null,
1029
- path = searchPath,
1030
- title = "Search: ${params.query}",
1031
- sections = arrayOf(untitledSection(emptyArray())),
1032
- children = null,
1033
- carPlaySiriListButton = null,
1034
- artwork = null,
1035
- artworkSource = null,
1036
- request = null,
1037
- artist = null,
1038
- albumPath = null,
1039
- description = null,
1040
- subtitle = null,
1041
- album = null,
1042
- genre = null,
1043
- duration = null,
1044
- src = null,
1045
- style = null,
1046
- disabled = null,
1047
- favorited = null,
1048
- live = null,
1049
- icyCharset = null,
1050
- )
1051
-
1052
- return emptySearchResult
1034
+ throw e
1053
1035
  }
1054
1036
  }
1055
1037