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.
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
- package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
- package/android/src/main/res/values/strings.xml +6 -21
- package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
- package/lib/commonjs/features/browser.js +8 -0
- package/lib/commonjs/features/browser.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +6 -8
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +4 -7
- package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
- package/lib/module/features/browser.js +8 -0
- package/lib/module/features/browser.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +6 -8
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +4 -7
- package/lib/module/web/browser/SearchManager.js.map +1 -1
- package/lib/typescript/src/features/browser.d.ts +8 -0
- package/lib/typescript/src/features/browser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/browser.ts +8 -0
- package/src/web/browser/BrowserManager.ts +6 -11
- package/src/web/browser/SearchManager.test.ts +75 -0
- package/src/web/browser/SearchManager.ts +7 -10
- 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
|
-
/**
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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
|
|