react-native-audio-browser 0.2.0-next.17 → 0.2.0-next.19
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/build.gradle +2 -1
- 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/CrossOriginHeaderStripper.kt +77 -0
- package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
- 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/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
- package/lib/commonjs/features/browser.js +8 -0
- package/lib/commonjs/features/browser.js.map +1 -1
- package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
- package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
- package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +30 -12
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +16 -19
- package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
- package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
- package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
- package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
- package/lib/module/features/browser.js +8 -0
- package/lib/module/features/browser.js.map +1 -1
- package/lib/module/web/NativeAudioBrowser.js +163 -50
- package/lib/module/web/NativeAudioBrowser.js.map +1 -1
- package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
- package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/module/web/TrackPlayer/Player.js +232 -39
- package/lib/module/web/TrackPlayer/Player.js.map +1 -1
- package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +30 -12
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +16 -19
- package/lib/module/web/browser/SearchManager.js.map +1 -1
- package/lib/module/web/http/RequestConfigBuilder.js +110 -27
- package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/module/web/http/followMediaRedirect.js +215 -0
- package/lib/module/web/http/followMediaRedirect.js.map +1 -0
- package/lib/typescript/src/features/browser.d.ts +8 -0
- package/lib/typescript/src/features/browser.d.ts.map +1 -1
- package/lib/typescript/src/types/browser.d.ts +13 -4
- package/lib/typescript/src/types/browser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/browser.ts +8 -0
- package/src/types/browser.ts +13 -4
- package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
- package/src/web/NativeAudioBrowser.ts +193 -60
- package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
- package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
- package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
- package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
- package/src/web/TrackPlayer/Player.load.test.ts +137 -1
- package/src/web/TrackPlayer/Player.ts +256 -37
- package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
- package/src/web/__tests__/lifecycle.test.ts +345 -0
- package/src/web/__tests__/lifecycleHarness.ts +351 -0
- package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
- package/src/web/__tests__/loadSupersession.test.ts +175 -0
- package/src/web/__tests__/retry.test.ts +169 -0
- package/src/web/browser/BrowserManager.ts +32 -15
- package/src/web/browser/SearchManager.test.ts +75 -0
- package/src/web/browser/SearchManager.ts +23 -26
- package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
- package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
- package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
- package/src/web/http/RequestConfigBuilder.test.ts +241 -1
- package/src/web/http/RequestConfigBuilder.ts +140 -33
- package/src/web/http/followMediaRedirect.test.ts +289 -0
- package/src/web/http/followMediaRedirect.ts +253 -0
package/android/build.gradle
CHANGED
|
@@ -199,6 +199,7 @@ dependencies {
|
|
|
199
199
|
implementation("androidx.media3:media3-exoplayer-dash:${media3Version}")
|
|
200
200
|
implementation("androidx.media3:media3-exoplayer-smoothstreaming:${media3Version}")
|
|
201
201
|
implementation("androidx.media3:media3-common:${media3Version}")
|
|
202
|
+
implementation("androidx.media3:media3-datasource-okhttp:${media3Version}")
|
|
202
203
|
|
|
203
204
|
implementation "com.google.guava:guava:33.0.0-android"
|
|
204
205
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0"
|
|
@@ -243,4 +244,4 @@ if (isNewArchitectureEnabled()) {
|
|
|
243
244
|
libraryName = "AudioBrowser"
|
|
244
245
|
codegenJavaPackageName = "com.audiobrowser"
|
|
245
246
|
}
|
|
246
|
-
}
|
|
247
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package com.audiobrowser.player
|
|
2
|
+
|
|
3
|
+
import okhttp3.HttpUrl
|
|
4
|
+
import okhttp3.Interceptor
|
|
5
|
+
import okhttp3.Request
|
|
6
|
+
import okhttp3.Response
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Drops the caller's headers once a redirect leaves the origin.
|
|
10
|
+
*
|
|
11
|
+
* OkHttp does this for `Authorization` alone — RetryAndFollowUpInterceptor.buildRedirectRequest is
|
|
12
|
+
* a single `removeHeader("Authorization")` — so every other configured header, an `X-Api-Key` say,
|
|
13
|
+
* still reaches whatever host the first one named. iOS forwards none across a hop and web clears
|
|
14
|
+
* them too; this brings Android in line.
|
|
15
|
+
*
|
|
16
|
+
* Installed as a **network** interceptor, which runs once per hop. `chain.call().request()` is the
|
|
17
|
+
* call's original request, so the comparison is always against the URL the credential was addressed
|
|
18
|
+
* to rather than the hop before.
|
|
19
|
+
*
|
|
20
|
+
* Scoped to the redirect hop, and only that. An HLS segment or key fetch is its own Call, already
|
|
21
|
+
* addressed to the absolute CDN URL, and [TransformingDataSource] deliberately re-applies the
|
|
22
|
+
* config's headers to it — authenticated segments are a supported shape, and web does the same
|
|
23
|
+
* through Shaka's request filter. So this closes the leak of handing a credential to a host the
|
|
24
|
+
* caller never named; it is not a rule that the credential stays on one origin.
|
|
25
|
+
*/
|
|
26
|
+
internal object CrossOriginHeaderStripper : Interceptor {
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Headers that describe the transport rather than the caller. OkHttp and media3 set these per hop
|
|
30
|
+
* — `Range` carries the seek, `Host` and `Connection` are the connection's own — so the request
|
|
31
|
+
* breaks without them. Everything else on a media request came from the media config, and is the
|
|
32
|
+
* caller's to send only to the host it addressed.
|
|
33
|
+
*
|
|
34
|
+
* `Icy-MetaData` is media3's, not the caller's: `ProgressiveMediaPeriod` asks for Shoutcast
|
|
35
|
+
* metadata through `ICY_METADATA_HEADERS` on the DataSpec — the same channel
|
|
36
|
+
* [TransformingDataSource] puts the config's headers on, so nothing downstream can tell them
|
|
37
|
+
* apart. Dropping it on a hop costs the station's `icy-metaint`, so `IcyDataSource` is never
|
|
38
|
+
* installed and `onMetadata` stops firing: live "now playing" disappears on exactly the
|
|
39
|
+
* redirecting streams this interceptor exists for, and the interleaved metadata bytes stay in the
|
|
40
|
+
* audio as audible chirping. It is a request for a response format, never a credential.
|
|
41
|
+
*/
|
|
42
|
+
private val TRANSPORT_HEADERS =
|
|
43
|
+
setOf(
|
|
44
|
+
"accept",
|
|
45
|
+
"accept-encoding",
|
|
46
|
+
"connection",
|
|
47
|
+
"content-length",
|
|
48
|
+
"host",
|
|
49
|
+
"icy-metadata",
|
|
50
|
+
"range",
|
|
51
|
+
"transfer-encoding",
|
|
52
|
+
"user-agent",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
56
|
+
val request = chain.request()
|
|
57
|
+
val addressed = chain.call().request().url
|
|
58
|
+
return chain.proceed(
|
|
59
|
+
if (sameOrigin(addressed, request.url)) request else stripCallerHeaders(request)
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Scheme, host and port — the same origin test `followMediaRedirect` applies on web. */
|
|
64
|
+
internal fun sameOrigin(a: HttpUrl, b: HttpUrl): Boolean =
|
|
65
|
+
a.scheme == b.scheme && a.host == b.host && a.port == b.port
|
|
66
|
+
|
|
67
|
+
/** The request with every caller-supplied header removed. */
|
|
68
|
+
internal fun stripCallerHeaders(request: Request): Request =
|
|
69
|
+
request
|
|
70
|
+
.newBuilder()
|
|
71
|
+
.apply {
|
|
72
|
+
request.headers.names().forEach { name ->
|
|
73
|
+
if (name.lowercase() !in TRANSPORT_HEADERS) removeHeader(name)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
.build()
|
|
77
|
+
}
|
|
@@ -4,10 +4,10 @@ import android.content.Context
|
|
|
4
4
|
import androidx.media3.common.MediaItem
|
|
5
5
|
import androidx.media3.datasource.DataSource
|
|
6
6
|
import androidx.media3.datasource.DefaultDataSource
|
|
7
|
-
import androidx.media3.datasource.DefaultHttpDataSource
|
|
8
7
|
import androidx.media3.datasource.TransferListener
|
|
9
8
|
import androidx.media3.datasource.cache.CacheDataSource
|
|
10
9
|
import androidx.media3.datasource.cache.SimpleCache
|
|
10
|
+
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|
11
11
|
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
|
12
12
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
13
13
|
import androidx.media3.exoplayer.source.MediaSource
|
|
@@ -16,6 +16,8 @@ import androidx.media3.extractor.DefaultExtractorsFactory
|
|
|
16
16
|
import com.audiobrowser.model.RetryPolicy
|
|
17
17
|
import com.margelo.nitro.audiobrowser.MediaRequestConfig
|
|
18
18
|
import java.io.IOException
|
|
19
|
+
import java.util.concurrent.TimeUnit
|
|
20
|
+
import okhttp3.OkHttpClient
|
|
19
21
|
|
|
20
22
|
class MediaFactory(
|
|
21
23
|
private val context: Context,
|
|
@@ -31,6 +33,20 @@ class MediaFactory(
|
|
|
31
33
|
|
|
32
34
|
companion object {
|
|
33
35
|
private const val DEFAULT_USER_AGENT = "react-native-audio-browser"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The client media requests go out on. One per process so connections are reused; not
|
|
39
|
+
* `HttpClient`'s browse client, whose read timeout is sized for JSON and would cut a long media
|
|
40
|
+
* read short. The timeouts are media3's own defaults, so stall detection and
|
|
41
|
+
* [RetryLoadErrorHandlingPolicy] behave as they did before.
|
|
42
|
+
*/
|
|
43
|
+
private val mediaHttpClient: OkHttpClient by lazy {
|
|
44
|
+
OkHttpClient.Builder()
|
|
45
|
+
.connectTimeout(8, TimeUnit.SECONDS)
|
|
46
|
+
.readTimeout(8, TimeUnit.SECONDS)
|
|
47
|
+
.addNetworkInterceptor(CrossOriginHeaderStripper)
|
|
48
|
+
.build()
|
|
49
|
+
}
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
private val extractorsFactory =
|
|
@@ -95,17 +111,19 @@ class MediaFactory(
|
|
|
95
111
|
// ExoPlayer's IO thread. This prevents deadlocks when the JS thread is blocked by
|
|
96
112
|
// synchronous Nitro calls (e.g., seekTo) while a media transform callback needs the
|
|
97
113
|
// JS thread to resolve.
|
|
114
|
+
|
|
115
|
+
// OkHttp rather than DefaultHttpDataSource, for how each treats a redirect.
|
|
116
|
+
// DefaultHttpDataSource re-applies the request headers on every hop, handing
|
|
117
|
+
// an Authorization header to whatever host the first one named. OkHttp drops
|
|
118
|
+
// that one header itself, and [CrossOriginHeaderStripper] drops the rest.
|
|
98
119
|
val httpFactory =
|
|
99
|
-
|
|
100
|
-
// Deliberately NOT setUserAgent():
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// request property keeps the default for un-overridden requests while letting
|
|
106
|
-
// the per-request DataSpec header win.
|
|
120
|
+
OkHttpDataSource.Factory(mediaHttpClient).apply {
|
|
121
|
+
// Deliberately NOT setUserAgent(): it is applied last via addHeader(),
|
|
122
|
+
// which appends, so a request already carrying one would send two. As a
|
|
123
|
+
// default request property it is merged under dataSpec.httpRequestHeaders
|
|
124
|
+
// and applied with header(), letting TransformingDataSource's per-request
|
|
125
|
+
// override win.
|
|
107
126
|
setDefaultRequestProperties(mapOf("User-Agent" to DEFAULT_USER_AGENT))
|
|
108
|
-
setAllowCrossProtocolRedirects(true)
|
|
109
127
|
// Connect transfer listener for bandwidth measurement
|
|
110
128
|
transferListener?.let { setTransferListener(it) }
|
|
111
129
|
}
|