react-native-audio-browser 0.2.0-next.17 → 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 (26) hide show
  1. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
  3. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
  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/src/features/browser.d.ts +8 -0
  20. package/lib/typescript/src/features/browser.d.ts.map +1 -1
  21. package/package.json +1 -1
  22. package/src/features/browser.ts +8 -0
  23. package/src/web/browser/BrowserManager.ts +6 -11
  24. package/src/web/browser/SearchManager.test.ts +75 -0
  25. package/src/web/browser/SearchManager.ts +7 -10
  26. 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
@@ -503,121 +507,48 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
503
507
 
504
508
  override fun getFormattedNavigationError(): FormattedNavigationError? = formattedNavigationError
505
509
 
506
- /** Creates a default formatted error from a NavigationError */
507
- private fun defaultFormattedError(error: NavigationError): FormattedNavigationError {
508
- val title =
509
- when (error.code) {
510
- NavigationErrorType.CONTENT_NOT_FOUND -> "Content Not Found"
511
- NavigationErrorType.NETWORK_ERROR -> "Network Error"
512
- NavigationErrorType.HTTP_ERROR -> {
513
- // Use system-localized HTTP status text (e.g., "Not Found", "Service Unavailable")
514
- error.statusCode?.let { httpStatusText(it.toInt()) } ?: "Server Error"
515
- }
516
- NavigationErrorType.CALLBACK_ERROR -> "Error"
517
- NavigationErrorType.UNKNOWN_ERROR -> "Error"
518
- // Not a failure — a container that resolved with no children. Neutral copy. See ADR 0001.
519
- NavigationErrorType.EMPTY_CONTENT -> "Nothing here"
520
- NavigationErrorType.TIMEOUT -> "Couldn't load"
521
- }
522
- // Omit an empty message so it renders as title-only (e.g. the empty-content case).
523
- return FormattedNavigationError(title, error.message.takeIf { it.isNotEmpty() })
524
- }
525
-
526
- /** Returns localized HTTP status text for the given status code */
527
- private fun httpStatusText(statusCode: Int): String {
528
- return when (statusCode) {
529
- 400 -> "Bad Request"
530
- 401 -> "Unauthorized"
531
- 403 -> "Forbidden"
532
- 404 -> "Not Found"
533
- 405 -> "Method Not Allowed"
534
- 408 -> "Request Timeout"
535
- 429 -> "Too Many Requests"
536
- 500 -> "Internal Server Error"
537
- 502 -> "Bad Gateway"
538
- 503 -> "Service Unavailable"
539
- 504 -> "Gateway Timeout"
540
- 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()
541
523
  }
542
524
  }
543
525
 
544
- private fun setNavigationError(
545
- code: NavigationErrorType,
546
- message: String,
547
- path: String,
548
- statusCode: Double? = null,
549
- statusCodeSuccess: Boolean? = null,
550
- ) {
551
- val navError = NavigationError(code, message, statusCode, statusCodeSuccess)
526
+ private fun setNavigationError(navError: NavigationError, path: String) {
552
527
  navigationError = navError
553
528
  onNavigationError(NavigationErrorEvent(navigationError))
554
529
 
555
- // Format the error (async if using JS callback, sync for defaults)
556
- val defaultFormatted = defaultFormattedError(navError)
557
- val formatter = _configuration.formatNavigationError
558
- if (formatter != null) {
559
- mainScope.launch {
560
- try {
561
- val params = FormatNavigationErrorParams(navError, defaultFormatted, path)
562
- val customFormatted = formatter(params).await()
563
- formattedNavigationError = customFormatted ?: defaultFormatted
564
- } catch (e: Exception) {
565
- formattedNavigationError = defaultFormatted
566
- }
567
- onFormattedNavigationError(formattedNavigationError)
568
- }
569
- } else {
570
- formattedNavigationError = defaultFormatted
571
- 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)
572
544
  }
573
545
  }
574
546
 
575
- /** Maps common browser exceptions to navigation errors */
547
+ /** Raises the navigation error [e] maps to, for a failed navigation to [path]. */
576
548
  private fun handleBrowserException(e: Exception, path: String, logContext: String) {
577
- when (e) {
578
- is HttpStatusException -> {
579
- Timber.e(e, "HTTP error $logContext")
580
- setNavigationError(
581
- NavigationErrorType.HTTP_ERROR,
582
- e.message ?: "Server error",
583
- path,
584
- e.statusCode.toDouble(),
585
- e.statusCode in 200..299,
586
- )
587
- }
588
- is NetworkException -> {
589
- Timber.e(e, "Network error $logContext")
590
- setNavigationError(
591
- NavigationErrorType.NETWORK_ERROR,
592
- e.message ?: "Network request failed",
593
- path,
594
- )
595
- }
596
- is ContentNotFoundException -> {
597
- Timber.e(e, "Content not found $logContext")
598
- setNavigationError(
599
- NavigationErrorType.CONTENT_NOT_FOUND,
600
- e.message ?: "Content not found",
601
- path,
602
- )
603
- }
604
- is CallbackException -> {
605
- Timber.e(e, "Callback error $logContext")
606
- setNavigationError(
607
- NavigationErrorType.CALLBACK_ERROR,
608
- e.message ?: "An error occurred",
609
- path,
610
- )
611
- }
612
- else -> {
613
- Timber.e(e, "Unexpected error $logContext")
614
- setNavigationError(
615
- NavigationErrorType.UNKNOWN_ERROR,
616
- e.message ?: "An unexpected error occurred",
617
- path,
618
- )
619
- }
620
- }
549
+ val navError = navigationErrorFor(e)
550
+ Timber.e(e, "${navError.code} $logContext")
551
+ setNavigationError(navError, path)
621
552
  }
622
553
 
623
554
  private fun clearNavigationError() {
@@ -769,7 +700,18 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
769
700
  override fun search(query: String): Promise<Array<Track>> {
770
701
  return Promise.async(mainScope) {
771
702
  Timber.d("Searching for: $query")
772
- 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
+ }
773
715
  // Flattened, not `children`: a resolved page carries its rows in
774
716
  // `sections` (ADR 0010) and search builds exactly one untitled section,
775
717
  // so `children` is always null here and this returned nothing at all.
@@ -1626,3 +1568,115 @@ private fun NativeRouteEntry.strippingJSCallbacks() =
1626
1568
  media = media?.strippingJSCallbacks(),
1627
1569
  artwork = artwork?.strippingJSCallbacks(),
1628
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