react-native-audio-browser 0.2.0-next.2 → 0.2.0-next.20
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/README.md +35 -21
- package/android/build.gradle +35 -12
- package/android/gradle.properties +2 -2
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +389 -164
- package/android/src/main/java/com/audiobrowser/AudioBrowserLifecycleModule.kt +38 -0
- package/android/src/main/java/com/audiobrowser/AudioBrowserPackage.kt +18 -2
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +48 -0
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +315 -213
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +24 -5
- package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
- package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +97 -58
- package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +70 -29
- package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +60 -0
- package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +20 -7
- package/android/src/main/java/com/audiobrowser/extension/TappedTrack.kt +20 -0
- package/android/src/main/java/com/audiobrowser/extension/TrackIdentity.kt +15 -0
- package/android/src/main/java/com/audiobrowser/http/HttpClient.kt +20 -3
- package/android/src/main/java/com/audiobrowser/model/IcyTitle.kt +24 -0
- package/android/src/main/java/com/audiobrowser/model/PlaybackMetadata.kt +10 -6
- package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +12 -12
- package/android/src/main/java/com/audiobrowser/player/CapabilityControls.kt +111 -58
- 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 +509 -230
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCommandManager.kt +218 -252
- package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +21 -25
- package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +113 -78
- package/android/src/main/java/com/audiobrowser/player/Player.kt +81 -38
- package/android/src/main/java/com/audiobrowser/player/PlayerCapabilitiesExt.kt +2 -15
- package/android/src/main/java/com/audiobrowser/player/PlayerListener.kt +1 -1
- package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
- package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
- package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +67 -11
- package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +168 -0
- package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
- package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +35 -7
- package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +278 -59
- package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
- package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +56 -40
- package/android/src/main/java/com/audiobrowser/util/MediaExtrasBuilder.kt +28 -23
- package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +108 -0
- package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +56 -52
- package/android/src/main/res/values/strings.xml +25 -5
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
- package/android/src/test/java/com/audiobrowser/TestFixtures.kt +119 -33
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerFavoritesTest.kt +153 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +15 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +346 -8
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +38 -0
- package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
- package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +3 -2
- package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +147 -0
- package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +128 -55
- package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +154 -0
- package/android/src/test/java/com/audiobrowser/extension/TappedTrackTest.kt +43 -0
- package/android/src/test/java/com/audiobrowser/extension/TrackIdentityTest.kt +41 -0
- package/android/src/test/java/com/audiobrowser/model/IcyTitleTest.kt +49 -0
- package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
- package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +101 -58
- package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
- package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
- package/android/src/test/java/com/audiobrowser/player/NowPlayingUpdaterTest.kt +42 -2
- package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
- package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +55 -0
- package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
- package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
- package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +197 -7
- package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +325 -4
- package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -0
- package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
- package/android/src/test/java/com/audiobrowser/util/BrowserPathHelperTest.kt +41 -16
- package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +162 -0
- package/android/src/test/java/com/audiobrowser/util/TrackFactoryMediaIdTest.kt +7 -7
- package/ios/Browser/BrowserConfig.swift +102 -5
- package/ios/Browser/BrowserManager+URLResolution.swift +18 -14
- package/ios/Browser/BrowserManager.swift +230 -205
- package/ios/Browser/BrowserPathHelper.swift +44 -44
- package/ios/Browser/InertStyleDiagnostic.swift +279 -0
- package/ios/Browser/JsonModels.swift +264 -106
- package/ios/Browser/SearchDrillIn.swift +7 -7
- package/ios/Browser/SectionPresentation.swift +64 -0
- package/ios/Browser/SectionScope.swift +58 -24
- package/ios/Browser/StyleResolver.swift +58 -0
- package/ios/Browser/TabBarEntries.swift +9 -3
- package/ios/Browser/TrackIdTemplate.swift +37 -0
- package/ios/CarPlay/CarPlayArtworkResolver.swift +8 -7
- package/ios/CarPlay/CarPlayController.swift +227 -85
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -20
- package/ios/CarPlay/CarPlayListItemFactory.swift +289 -84
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +15 -12
- package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
- package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
- package/ios/Extension/Capability+RemoteCommand.swift +1 -1
- package/ios/Extension/PlayerCapabilities+Favorite.swift +1 -15
- package/ios/Extension/ResolvedTrack+Copying.swift +13 -15
- package/ios/Extension/Section+Copying.swift +29 -0
- package/ios/Extension/String+IcyCharset.swift +21 -0
- package/ios/Extension/TimedMetadata+AVFoundation.swift +6 -4
- package/ios/Extension/Track+Copying.swift +8 -12
- package/ios/Extension/Track+Identity.swift +18 -0
- package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
- package/ios/Http/HttpClient.swift +8 -2
- package/ios/HybridAudioBrowser.swift +253 -100
- package/ios/Model/NitroTypeStubs.swift +125 -23
- package/ios/Model/PlayerUpdateOptions.swift +7 -0
- package/ios/Model/RemoteCommand.swift +18 -0
- package/ios/Model/TrackPlayerError.swift +2 -2
- package/ios/Option/SessionCategories.swift +3 -2
- package/ios/Player/MediaIntentCriteria.swift +17 -9
- package/ios/Player/MediaLoader.swift +33 -9
- package/ios/Player/NowPlayingUpdater.swift +54 -6
- package/ios/Player/PlaybackCoordinator.swift +13 -6
- package/ios/Player/PlaybackErrorHandler.swift +4 -4
- package/ios/Player/PlaybackTimer.swift +8 -2
- package/ios/Player/QueueManager.swift +46 -13
- package/ios/Player/RetryManager.swift +80 -18
- package/ios/Player/SeekCompletionHandler.swift +3 -1
- package/ios/Player/ShuffleOrder.swift +33 -0
- package/ios/Player/SleepTimerHandling.swift +13 -0
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
- package/ios/RemoteCommand/RemoteCommandController.swift +62 -24
- package/ios/Tests/BrowserPathHelperTests.swift +50 -20
- package/ios/Tests/CarPlayArtworkResolverTests.swift +24 -41
- package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
- package/ios/Tests/JsonModelsTests.swift +150 -1
- package/ios/Tests/LRUCacheTests.swift +111 -0
- package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
- package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
- package/ios/Tests/PlaybackCoordinatorTests.swift +1 -1
- package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
- package/ios/Tests/PlaybackStateStoreTests.swift +4 -3
- package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
- package/ios/Tests/QueueManagerTests.swift +122 -10
- package/ios/Tests/RemoteCommandControllerTests.swift +172 -0
- package/ios/Tests/RemoteCommandTests.swift +67 -0
- package/ios/Tests/ResolvedTrackCopyingTests.swift +39 -35
- package/ios/Tests/RetryManagerTests.swift +89 -0
- package/ios/Tests/SearchDrillInTests.swift +16 -16
- package/ios/Tests/SectionPresentationTests.swift +90 -0
- package/ios/Tests/SectionScopeTests.swift +87 -55
- package/ios/Tests/StyleResolverTests.swift +127 -0
- package/ios/Tests/Support/TestTypes.swift +8 -1
- package/ios/Tests/TabBarEntriesTests.swift +19 -19
- package/ios/Tests/TimedMetadataAVFoundationTests.swift +42 -0
- package/ios/Tests/TrackIdTemplateTests.swift +67 -0
- package/ios/Tests/TrackIdentityTests.swift +20 -0
- package/ios/Tests/TrackSelectorTests.swift +168 -33
- package/ios/TrackPlayer.swift +63 -35
- package/ios/TrackPlayerCallbacks.swift +5 -38
- package/ios/TrackSelector.swift +45 -30
- package/ios/Util/ArtworkImageFetcher.swift +16 -1
- package/ios/Util/LRUCache.swift +4 -1
- package/ios/Util/NetworkMonitor.swift +43 -26
- package/ios/Util/SFSymbolRenderer.swift +24 -2
- package/ios/Util/SVGProcessor.swift +9 -2
- package/lib/commonjs/AudioBrowser.js +8 -8
- package/lib/commonjs/AudioBrowser.js.map +1 -1
- package/lib/commonjs/CLAUDE.md +52 -0
- package/lib/commonjs/features/browser-config.js +12 -8
- package/lib/commonjs/features/browser-config.js.map +1 -1
- package/lib/commonjs/features/browser.js +11 -3
- package/lib/commonjs/features/browser.js.map +1 -1
- package/lib/commonjs/features/carConnection.js +1 -1
- package/lib/commonjs/features/equalizer.js +1 -1
- package/lib/commonjs/features/errors.js +3 -3
- package/lib/commonjs/features/favorites.js +14 -11
- package/lib/commonjs/features/favorites.js.map +1 -1
- package/lib/commonjs/features/gate.js +16 -1
- package/lib/commonjs/features/gate.js.map +1 -1
- package/lib/commonjs/features/metadata.js +20 -3
- package/lib/commonjs/features/metadata.js.map +1 -1
- package/lib/commonjs/features/network.js +1 -1
- package/lib/commonjs/features/nowPlaying.js +1 -1
- package/lib/commonjs/features/output.js +1 -1
- package/lib/commonjs/features/playback/controls.js +4 -2
- package/lib/commonjs/features/playback/controls.js.map +1 -1
- package/lib/commonjs/features/playback/playWhenReady.js +1 -1
- package/lib/commonjs/features/playback/playing.js +1 -1
- package/lib/commonjs/features/playback/progress.js +3 -2
- package/lib/commonjs/features/playback/progress.js.map +1 -1
- package/lib/commonjs/features/playback/rate.js +8 -1
- package/lib/commonjs/features/playback/rate.js.map +1 -1
- package/lib/commonjs/features/playback/state.js +3 -4
- package/lib/commonjs/features/playback/state.js.map +1 -1
- package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
- package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
- package/lib/commonjs/features/playback/volume.js +1 -1
- package/lib/commonjs/features/player/options.js +48 -34
- package/lib/commonjs/features/player/options.js.map +1 -1
- package/lib/commonjs/features/player/setup.js +18 -9
- package/lib/commonjs/features/player/setup.js.map +1 -1
- package/lib/commonjs/features/player/validateOptions.js +23 -0
- package/lib/commonjs/features/player/validateOptions.js.map +1 -0
- package/lib/commonjs/features/queue/activeTrack.js +15 -4
- package/lib/commonjs/features/queue/activeTrack.js.map +1 -1
- package/lib/commonjs/features/queue/queue.js +13 -6
- package/lib/commonjs/features/queue/queue.js.map +1 -1
- package/lib/commonjs/features/queue/repeatMode.js +1 -1
- package/lib/commonjs/features/queue/shuffle.js +1 -1
- package/lib/commonjs/features/remoteControls.js +13 -92
- package/lib/commonjs/features/remoteControls.js.map +1 -1
- package/lib/commonjs/features/sleepTimer.js +13 -1
- package/lib/commonjs/features/sleepTimer.js.map +1 -1
- package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
- package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
- package/lib/commonjs/utils/LazyNativeEmitter.js +28 -14
- package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
- package/lib/commonjs/utils/NativeUpdatedValue.js +31 -13
- package/lib/commonjs/utils/NativeUpdatedValue.js.map +1 -1
- package/lib/commonjs/utils/getTrackIdentity.js +24 -0
- package/lib/commonjs/utils/getTrackIdentity.js.map +1 -0
- package/lib/commonjs/utils/useNativeValueRefreshedBy.js +33 -0
- package/lib/commonjs/utils/useNativeValueRefreshedBy.js.map +1 -0
- package/lib/commonjs/web/NativeAudioBrowser.js +200 -75
- 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 +254 -39
- package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
- package/lib/commonjs/web/TrackPlayer/QueueManager.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 +175 -100
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/FavoriteManager.js +30 -21
- package/lib/commonjs/web/browser/FavoriteManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +18 -23
- package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
- package/lib/commonjs/web/browser/assertBrowsePageShape.js +2 -2
- package/lib/commonjs/web/browser/assertBrowsePageShape.js.map +1 -1
- package/lib/commonjs/web/browser/classifyTrackNavigation.js +9 -9
- package/lib/commonjs/web/browser/classifyTrackNavigation.js.map +1 -1
- package/lib/commonjs/web/browser/sections.js +89 -0
- package/lib/commonjs/web/browser/sections.js.map +1 -0
- 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/commonjs/web/player/NowPlayingManager.js +4 -3
- package/lib/commonjs/web/player/NowPlayingManager.js.map +1 -1
- package/lib/commonjs/web/util/BrowserPathHelper.js +56 -11
- package/lib/commonjs/web/util/BrowserPathHelper.js.map +1 -1
- package/lib/module/AudioBrowser.js +2 -1
- package/lib/module/AudioBrowser.js.map +1 -1
- package/lib/module/CLAUDE.md +52 -0
- package/lib/module/features/browser-config.js +12 -8
- package/lib/module/features/browser-config.js.map +1 -1
- package/lib/module/features/browser.js +11 -3
- package/lib/module/features/browser.js.map +1 -1
- package/lib/module/features/carConnection.js +1 -1
- package/lib/module/features/equalizer.js +1 -1
- package/lib/module/features/errors.js +3 -3
- package/lib/module/features/favorites.js +14 -11
- package/lib/module/features/favorites.js.map +1 -1
- package/lib/module/features/gate.js +16 -1
- package/lib/module/features/gate.js.map +1 -1
- package/lib/module/features/metadata.js +20 -3
- package/lib/module/features/metadata.js.map +1 -1
- package/lib/module/features/network.js +1 -1
- package/lib/module/features/nowPlaying.js +1 -1
- package/lib/module/features/output.js +1 -1
- package/lib/module/features/playback/controls.js +4 -2
- package/lib/module/features/playback/controls.js.map +1 -1
- package/lib/module/features/playback/playWhenReady.js +1 -1
- package/lib/module/features/playback/playing.js +1 -1
- package/lib/module/features/playback/progress.js +3 -2
- package/lib/module/features/playback/progress.js.map +1 -1
- package/lib/module/features/playback/rate.js +8 -1
- package/lib/module/features/playback/rate.js.map +1 -1
- package/lib/module/features/playback/state.js +3 -4
- package/lib/module/features/playback/state.js.map +1 -1
- package/lib/module/features/playback/trackPlaybackTime.js +0 -5
- package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
- package/lib/module/features/playback/volume.js +1 -1
- package/lib/module/features/player/options.js +47 -32
- package/lib/module/features/player/options.js.map +1 -1
- package/lib/module/features/player/setup.js +17 -8
- package/lib/module/features/player/setup.js.map +1 -1
- package/lib/module/features/player/validateOptions.js +19 -0
- package/lib/module/features/player/validateOptions.js.map +1 -0
- package/lib/module/features/queue/activeTrack.js +15 -4
- package/lib/module/features/queue/activeTrack.js.map +1 -1
- package/lib/module/features/queue/queue.js +13 -6
- package/lib/module/features/queue/queue.js.map +1 -1
- package/lib/module/features/queue/repeatMode.js +1 -1
- package/lib/module/features/queue/shuffle.js +1 -1
- package/lib/module/features/remoteControls.js +13 -92
- package/lib/module/features/remoteControls.js.map +1 -1
- package/lib/module/features/sleepTimer.js +13 -1
- package/lib/module/features/sleepTimer.js.map +1 -1
- package/lib/module/test-utils/reactNativeStub.js +58 -0
- package/lib/module/test-utils/reactNativeStub.js.map +1 -0
- package/lib/module/utils/LazyNativeEmitter.js +28 -14
- package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
- package/lib/module/utils/NativeUpdatedValue.js +31 -13
- package/lib/module/utils/NativeUpdatedValue.js.map +1 -1
- package/lib/module/utils/getTrackIdentity.js +20 -0
- package/lib/module/utils/getTrackIdentity.js.map +1 -0
- package/lib/module/utils/useNativeValueRefreshedBy.js +29 -0
- package/lib/module/utils/useNativeValueRefreshedBy.js.map +1 -0
- package/lib/module/web/NativeAudioBrowser.js +200 -75
- 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 +254 -39
- package/lib/module/web/TrackPlayer/Player.js.map +1 -1
- package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
- package/lib/module/web/TrackPlayer/QueueManager.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 +175 -100
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/FavoriteManager.js +31 -21
- package/lib/module/web/browser/FavoriteManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +18 -23
- package/lib/module/web/browser/SearchManager.js.map +1 -1
- package/lib/module/web/browser/assertBrowsePageShape.js +2 -2
- package/lib/module/web/browser/assertBrowsePageShape.js.map +1 -1
- package/lib/module/web/browser/classifyTrackNavigation.js +9 -9
- package/lib/module/web/browser/classifyTrackNavigation.js.map +1 -1
- package/lib/module/web/browser/sections.js +83 -0
- package/lib/module/web/browser/sections.js.map +1 -0
- 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/module/web/player/NowPlayingManager.js +4 -3
- package/lib/module/web/player/NowPlayingManager.js.map +1 -1
- package/lib/module/web/util/BrowserPathHelper.js +56 -11
- package/lib/module/web/util/BrowserPathHelper.js.map +1 -1
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioBrowser.d.ts +1 -1
- package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
- package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
- package/lib/typescript/src/features/browser.d.ts +10 -2
- package/lib/typescript/src/features/browser.d.ts.map +1 -1
- package/lib/typescript/src/features/carConnection.d.ts +1 -1
- package/lib/typescript/src/features/equalizer.d.ts +1 -1
- package/lib/typescript/src/features/errors.d.ts +3 -3
- package/lib/typescript/src/features/favorites.d.ts +14 -11
- package/lib/typescript/src/features/favorites.d.ts.map +1 -1
- package/lib/typescript/src/features/gate.d.ts +18 -2
- package/lib/typescript/src/features/gate.d.ts.map +1 -1
- package/lib/typescript/src/features/metadata.d.ts +20 -3
- package/lib/typescript/src/features/metadata.d.ts.map +1 -1
- package/lib/typescript/src/features/network.d.ts +1 -1
- package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
- package/lib/typescript/src/features/output.d.ts +1 -1
- package/lib/typescript/src/features/playback/controls.d.ts +4 -2
- package/lib/typescript/src/features/playback/controls.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
- package/lib/typescript/src/features/playback/playing.d.ts +1 -1
- package/lib/typescript/src/features/playback/progress.d.ts +1 -1
- package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/rate.d.ts +8 -1
- package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/state.d.ts +3 -4
- package/lib/typescript/src/features/playback/state.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts +1 -1
- package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/volume.d.ts +1 -1
- package/lib/typescript/src/features/player/options.d.ts +108 -56
- package/lib/typescript/src/features/player/options.d.ts.map +1 -1
- package/lib/typescript/src/features/player/setup.d.ts +14 -4
- package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
- package/lib/typescript/src/features/player/validateOptions.d.ts +2 -0
- package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
- package/lib/typescript/src/features/queue/activeTrack.d.ts +9 -2
- package/lib/typescript/src/features/queue/activeTrack.d.ts.map +1 -1
- package/lib/typescript/src/features/queue/queue.d.ts +7 -4
- package/lib/typescript/src/features/queue/queue.d.ts.map +1 -1
- package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
- package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
- package/lib/typescript/src/features/remoteControls.d.ts +23 -22
- package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
- package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
- package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
- package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
- package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
- package/lib/typescript/src/types/browser-native.d.ts +3 -2
- package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
- package/lib/typescript/src/types/browser-nodes.d.ts +380 -126
- package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
- package/lib/typescript/src/types/browser.d.ts +82 -57
- package/lib/typescript/src/types/browser.d.ts.map +1 -1
- package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +19 -16
- package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
- package/lib/typescript/src/utils/NativeUpdatedValue.d.ts +24 -19
- package/lib/typescript/src/utils/NativeUpdatedValue.d.ts.map +1 -1
- package/lib/typescript/src/utils/getTrackIdentity.d.ts +18 -0
- package/lib/typescript/src/utils/getTrackIdentity.d.ts.map +1 -0
- package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts +21 -0
- package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts.map +1 -0
- package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -2
- package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
- package/nitrogen/generated/android/c++/{JFavoritesMatchMode.hpp → JArtworkRendering.hpp} +15 -15
- package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +8 -3
- package/nitrogen/generated/android/c++/JBrowseResult.hpp +16 -4
- package/nitrogen/generated/android/c++/JCarPlayPlayingIndicatorLocation.hpp +58 -0
- package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
- package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +8 -3
- package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +8 -3
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +8 -3
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +8 -3
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +8 -3
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +8 -3
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +8 -2
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +8 -2
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__string__.hpp +102 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +16 -4
- package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +8 -3
- package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +7 -10
- package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +8 -3
- package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +15 -3
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +8 -2
- package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +45 -25
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
- package/nitrogen/generated/android/c++/JIOSOptions.hpp +9 -3
- package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
- package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +8 -3
- package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +36 -11
- package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +14 -2
- package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +15 -12
- package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +7 -10
- package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
- package/nitrogen/generated/android/c++/JNitroIOSUpdateOptions.hpp +9 -3
- package/nitrogen/generated/android/c++/JOptions.hpp +7 -10
- package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +8 -3
- package/nitrogen/generated/android/c++/JPlayerCapabilities.hpp +5 -11
- package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
- package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
- package/nitrogen/generated/android/c++/JResolvedTrack.hpp +57 -49
- package/nitrogen/generated/android/c++/JSection.hpp +116 -0
- package/nitrogen/generated/android/c++/JSectionStyle.hpp +96 -0
- package/nitrogen/generated/android/c++/JStyleDisplay.hpp +58 -0
- package/nitrogen/generated/android/c++/JTrack.hpp +25 -46
- package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +8 -2
- package/nitrogen/generated/android/c++/JTrackStyle.hpp +46 -18
- package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
- package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +16 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoritesMatchMode.kt → ArtworkRendering.kt} +5 -5
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CarPlayPlayingIndicatorLocation.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_std__shared_ptr_Promise_std__string__.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/IOSOptions.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +8 -5
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroIOSUpdateOptions.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/PlayerCapabilities.kt +2 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ResolvedTrack.kt +12 -15
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoriteConfig.kt → Section.kt} +15 -6
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +59 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/StyleDisplay.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Track.kt +6 -12
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +35 -5
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +9 -0
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +244 -133
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +31 -16
- package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +33 -18
- package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
- package/nitrogen/generated/ios/swift/ArtworkRendering.swift +40 -0
- package/nitrogen/generated/ios/swift/CarPlayPlayingIndicatorLocation.swift +40 -0
- package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
- package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__string__.swift +54 -0
- package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
- package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
- package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
- package/nitrogen/generated/ios/swift/IOSOptions.swift +7 -2
- package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
- package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +52 -5
- package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
- package/nitrogen/generated/ios/swift/NitroIOSUpdateOptions.swift +12 -1
- package/nitrogen/generated/ios/swift/PlayerCapabilities.swift +8 -27
- package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
- package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
- package/nitrogen/generated/ios/swift/ResolvedTrack.swift +62 -73
- package/nitrogen/generated/ios/swift/Section.swift +82 -0
- package/nitrogen/generated/ios/swift/SectionStyle.swift +133 -0
- package/nitrogen/generated/ios/swift/StyleDisplay.swift +40 -0
- package/nitrogen/generated/ios/swift/Track.swift +28 -63
- package/nitrogen/generated/ios/swift/TrackStyle.swift +86 -22
- package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
- package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
- package/nitrogen/generated/shared/c++/{FavoritesMatchMode.hpp → ArtworkRendering.hpp} +17 -17
- package/nitrogen/generated/shared/c++/CarPlayPlayingIndicatorLocation.hpp +76 -0
- package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
- package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
- package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
- package/nitrogen/generated/shared/c++/IOSOptions.hpp +9 -2
- package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
- package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +10 -6
- package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
- package/nitrogen/generated/shared/c++/NitroIOSUpdateOptions.hpp +9 -2
- package/nitrogen/generated/shared/c++/PlayerCapabilities.hpp +6 -9
- package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
- package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
- package/nitrogen/generated/shared/c++/ResolvedTrack.hpp +33 -37
- package/nitrogen/generated/shared/c++/Section.hpp +102 -0
- package/nitrogen/generated/shared/c++/SectionStyle.hpp +126 -0
- package/nitrogen/generated/shared/c++/StyleDisplay.hpp +76 -0
- package/nitrogen/generated/shared/c++/Track.hpp +18 -30
- package/nitrogen/generated/shared/c++/TrackStyle.hpp +73 -34
- package/package.json +24 -11
- package/src/AudioBrowser.ts +2 -1
- package/src/CLAUDE.md +52 -0
- package/src/features/battery.test.ts +79 -0
- package/src/features/browser-config.test.ts +21 -6
- package/src/features/browser-config.ts +12 -8
- package/src/features/browser.test.ts +1 -1
- package/src/features/browser.ts +11 -3
- package/src/features/carConnection.ts +1 -1
- package/src/features/equalizer.ts +1 -1
- package/src/features/errors.ts +3 -3
- package/src/features/favorites.ts +14 -11
- package/src/features/gate.ts +16 -1
- package/src/features/hook-wiring.test.ts +302 -0
- package/src/features/metadata.ts +20 -3
- package/src/features/network.ts +1 -1
- package/src/features/nowPlaying.ts +1 -1
- package/src/features/output.ts +1 -1
- package/src/features/playback/controls.ts +4 -2
- package/src/features/playback/playWhenReady.ts +1 -1
- package/src/features/playback/playing.ts +1 -1
- package/src/features/playback/progress.test.ts +169 -0
- package/src/features/playback/progress.ts +3 -2
- package/src/features/playback/rate.ts +8 -1
- package/src/features/playback/state.ts +3 -4
- package/src/features/playback/trackPlaybackTime.test.ts +16 -6
- package/src/features/playback/trackPlaybackTime.ts +1 -6
- package/src/features/playback/volume.ts +1 -1
- package/src/features/player/options.ts +126 -76
- package/src/features/player/setup.test.ts +24 -7
- package/src/features/player/setup.ts +22 -10
- package/src/features/player/validateOptions.ts +21 -0
- package/src/features/queue/activeTrack.ts +15 -4
- package/src/features/queue/queue.ts +13 -6
- package/src/features/queue/repeatMode.ts +1 -1
- package/src/features/queue/shuffle.ts +1 -1
- package/src/features/remoteControls.ts +13 -92
- package/src/features/sleepTimer.test.ts +172 -0
- package/src/features/sleepTimer.ts +9 -1
- package/src/nitro-stub-drift.test.ts +277 -0
- package/src/public-surface.test.ts +147 -0
- package/src/specs/audio-browser.nitro.ts +5 -4
- package/src/style-resolution.test.ts +210 -0
- package/src/test-utils/reactNativeStub.ts +60 -0
- package/src/types/browser-native.ts +7 -3
- package/src/types/browser-nodes.ts +396 -134
- package/src/types/browser.ts +85 -59
- package/src/utils/LazyNativeEmitter.ts +28 -15
- package/src/utils/NativeUpdatedValue.ts +29 -13
- package/src/utils/getTrackIdentity.ts +20 -0
- package/src/utils/useDebug.test.ts +182 -0
- package/src/utils/useNativeUpdatedValue.test.ts +103 -0
- package/src/utils/useNativeValueRefreshedBy.ts +38 -0
- package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
- package/src/web/NativeAudioBrowser.ts +239 -82
- 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 +284 -37
- package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
- package/src/web/TrackPlayer/QueueManager.ts +14 -0
- 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 +246 -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 +209 -111
- package/src/web/browser/FavoriteManager.test.ts +84 -0
- package/src/web/browser/FavoriteManager.ts +28 -23
- package/src/web/browser/SearchManager.test.ts +75 -0
- package/src/web/browser/SearchManager.ts +25 -28
- 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-artwork.test.ts +46 -0
- package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
- package/src/web/browser/assertBrowsePageShape.test.ts +1 -1
- package/src/web/browser/assertBrowsePageShape.ts +5 -3
- package/src/web/browser/classifyTrackNavigation.test.ts +10 -8
- package/src/web/browser/classifyTrackNavigation.ts +9 -9
- package/src/web/browser/parseSearchResponse.test.ts +8 -2
- package/src/web/browser/sections.test.ts +91 -0
- package/src/web/browser/sections.ts +72 -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/src/web/player/NowPlayingManager.ts +4 -3
- package/src/web/util/BrowserPathHelper.test.ts +52 -0
- package/src/web/util/BrowserPathHelper.ts +61 -13
- package/website/guide/android-auto.md +114 -0
- package/website/guide/android-certificates.md +409 -0
- package/website/guide/artwork.md +255 -0
- package/website/guide/audio-output.md +135 -0
- package/website/guide/automotive.md +86 -0
- package/website/guide/basic-usage.md +151 -0
- package/website/guide/battery.md +115 -0
- package/website/guide/browser.md +581 -0
- package/website/guide/carplay.md +620 -0
- package/website/guide/configuration.md +173 -0
- package/website/guide/equalizer.md +231 -0
- package/website/guide/errors.md +310 -0
- package/website/guide/favorites.md +154 -0
- package/website/guide/gate.md +143 -0
- package/website/guide/getting-started.md +46 -0
- package/website/guide/hooks.md +238 -0
- package/website/guide/live-streams.md +125 -0
- package/website/guide/metadata.md +239 -0
- package/website/guide/migrating-from-track-player.md +244 -0
- package/website/guide/native-callback-fetch.md +127 -0
- package/website/guide/network.md +86 -0
- package/website/guide/now-playing.md +175 -0
- package/website/guide/playback.md +328 -0
- package/website/guide/queue.md +300 -0
- package/website/guide/remote-controls.md +256 -0
- package/website/guide/search.md +255 -0
- package/website/guide/sleep-timer.md +268 -0
- package/website/guide/track.md +247 -0
- package/ios/CarPlay/ImageRowItem+CarPlay.swift +0 -19
- package/ios/Model/MediaURL.swift +0 -67
- package/nitrogen/generated/android/c++/JFavoriteConfig.hpp +0 -58
- package/nitrogen/generated/android/c++/JImageRowItem.hpp +0 -105
- package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
- package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.cpp +0 -26
- package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.hpp +0 -71
- package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
- package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageRowItem.kt +0 -68
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Variant_Boolean_FavoriteConfig.kt +0 -53
- package/nitrogen/generated/ios/swift/FavoriteConfig.swift +0 -29
- package/nitrogen/generated/ios/swift/FavoritesMatchMode.swift +0 -40
- package/nitrogen/generated/ios/swift/ImageRowItem.swift +0 -195
- package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
- package/nitrogen/generated/ios/swift/Variant_Bool_FavoriteConfig.swift +0 -18
- package/nitrogen/generated/shared/c++/FavoriteConfig.hpp +0 -84
- package/nitrogen/generated/shared/c++/ImageRowItem.hpp +0 -129
- package/nitrogen/generated/shared/c++/NotificationButtonLayout.hpp +0 -102
|
@@ -11,22 +11,32 @@ import androidx.media3.session.MediaSession
|
|
|
11
11
|
import androidx.media3.session.SessionCommand
|
|
12
12
|
import androidx.media3.session.SessionError
|
|
13
13
|
import androidx.media3.session.SessionResult
|
|
14
|
+
import com.audiobrowser.browser.BrowserManager
|
|
14
15
|
import com.audiobrowser.browser.handleTrackLoad
|
|
16
|
+
import com.audiobrowser.browser.normalizedSections
|
|
17
|
+
import com.audiobrowser.browser.styleResolvedSections
|
|
18
|
+
import com.audiobrowser.browser.untitledSection
|
|
19
|
+
import com.audiobrowser.extension.indexOfTappedTrack
|
|
15
20
|
import com.audiobrowser.extension.toTrack
|
|
21
|
+
import com.audiobrowser.navigationErrorFor
|
|
16
22
|
import com.audiobrowser.util.BrowserPathHelper
|
|
17
23
|
import com.audiobrowser.util.RatingFavorites
|
|
18
24
|
import com.audiobrowser.util.TrackFactory
|
|
19
25
|
import com.google.common.collect.ImmutableList
|
|
20
26
|
import com.google.common.util.concurrent.Futures
|
|
21
27
|
import com.google.common.util.concurrent.ListenableFuture
|
|
28
|
+
import com.margelo.nitro.audiobrowser.FormattedNavigationError
|
|
22
29
|
import com.margelo.nitro.audiobrowser.Gate
|
|
23
30
|
import com.margelo.nitro.audiobrowser.GateEvent
|
|
24
31
|
import com.margelo.nitro.audiobrowser.GateReason
|
|
25
32
|
import com.margelo.nitro.audiobrowser.MediaReference
|
|
26
33
|
import com.margelo.nitro.audiobrowser.NativeGateRequest
|
|
27
|
-
import com.margelo.nitro.audiobrowser.
|
|
34
|
+
import com.margelo.nitro.audiobrowser.NavigationError
|
|
35
|
+
import com.margelo.nitro.audiobrowser.NavigationErrorType
|
|
28
36
|
import com.margelo.nitro.audiobrowser.PlayerCapabilities
|
|
37
|
+
import com.margelo.nitro.audiobrowser.RemoteButtonLayout
|
|
29
38
|
import com.margelo.nitro.audiobrowser.SearchParams
|
|
39
|
+
import com.margelo.nitro.audiobrowser.Section
|
|
30
40
|
import com.margelo.nitro.audiobrowser.Track
|
|
31
41
|
import kotlinx.coroutines.CancellationException
|
|
32
42
|
import kotlinx.coroutines.CoroutineScope
|
|
@@ -40,8 +50,12 @@ import kotlinx.coroutines.withContext
|
|
|
40
50
|
import timber.log.Timber
|
|
41
51
|
|
|
42
52
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
53
|
+
* Everything the MediaLibrarySession serves except transport: browse, search, item lookup, queue
|
|
54
|
+
* building, playback resumption, and the favorite and rating commands. Transport does not arrive
|
|
55
|
+
* here — it reaches the player through [InterceptingPlayer], and Assistant's play-from-search
|
|
56
|
+
* arrives as an intent handled in the service. The callbacks that answer through [scope] run on IO,
|
|
57
|
+
* so anything touching the ExoPlayer instance hops back to [Dispatchers.Main]; the rest answer
|
|
58
|
+
* synchronously on the Media3 application thread.
|
|
45
59
|
*/
|
|
46
60
|
class MediaSessionCallback(private val player: Player) :
|
|
47
61
|
MediaLibraryService.MediaLibrarySession.Callback {
|
|
@@ -53,6 +67,8 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
53
67
|
// threads (network observer, JS-triggered notifies) — guard all access with synchronized(this).
|
|
54
68
|
private val parentIdSubscriptions =
|
|
55
69
|
mutableMapOf<String, MutableSet<MediaSession.ControllerInfo>>()
|
|
70
|
+
private val failedSearch = FailedSearchSlot()
|
|
71
|
+
private val offlineSearches = OfflineSearchRegistry()
|
|
56
72
|
private var mediaLibrarySession: MediaLibraryService.MediaLibrarySession? = null
|
|
57
73
|
|
|
58
74
|
/**
|
|
@@ -64,14 +80,15 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
64
80
|
private set
|
|
65
81
|
|
|
66
82
|
init {
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
player.networkMonitor.observeOnline(scope) { online ->
|
|
84
|
+
notifySubscribedChildrenChanged()
|
|
85
|
+
// A browse level is re-queried through its subscription; a search has none, so its offline
|
|
86
|
+
// tile has to be taken off the screen by re-running the query.
|
|
87
|
+
if (online) scope.launch { reannounceOfflineSearches() }
|
|
88
|
+
}
|
|
69
89
|
}
|
|
70
90
|
|
|
71
|
-
/**
|
|
72
|
-
* Apply pagination to a list of items. If pageSize is 0 or MAX_VALUE (Android Auto default),
|
|
73
|
-
* returns the full list.
|
|
74
|
-
*/
|
|
91
|
+
/** Media3's legacy browse bridge asks for a whole list with pageSize [Int.MAX_VALUE]. */
|
|
75
92
|
private fun <T> List<T>.paginate(page: Int, pageSize: Int): List<T> {
|
|
76
93
|
return if (pageSize in 1 until Int.MAX_VALUE) {
|
|
77
94
|
this.drop(page * pageSize).take(pageSize)
|
|
@@ -80,45 +97,6 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
80
97
|
}
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
/** Creates an offline error MediaItem. */
|
|
84
|
-
private fun createOfflineMediaItem(): MediaItem =
|
|
85
|
-
createErrorMediaItem(
|
|
86
|
-
mediaId = BrowserPathHelper.OFFLINE_PATH,
|
|
87
|
-
title = player.context.getString(com.audiobrowser.R.string.audio_browser_offline_error),
|
|
88
|
-
subtitle =
|
|
89
|
-
player.context.getString(com.audiobrowser.R.string.audio_browser_offline_error_subtitle),
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Builds a non-browsable, non-playable [MediaItem] used to surface an error inside an Android
|
|
94
|
-
* Auto / AAOS browse list. Rendered as a greyed-out, non-interactive tile, which is the only
|
|
95
|
-
* side-effect-free way to communicate a browse failure to legacy controllers (Media3 drops
|
|
96
|
-
* [LibraryResult.ofError] on the legacy browse bridge, leaving an empty "No items" screen).
|
|
97
|
-
*/
|
|
98
|
-
private fun createErrorMediaItem(mediaId: String, title: String, subtitle: String): MediaItem =
|
|
99
|
-
MediaItem.Builder()
|
|
100
|
-
.setMediaId(mediaId)
|
|
101
|
-
.setMediaMetadata(
|
|
102
|
-
MediaMetadata.Builder()
|
|
103
|
-
.setTitle(title)
|
|
104
|
-
.setSubtitle(subtitle)
|
|
105
|
-
// Non-browsable, non-playable. Android Auto ignores these flags and still drills into a
|
|
106
|
-
// tapped tile, but onGetChildren returns an empty list for the sentinel paths so it's a
|
|
107
|
-
// harmless "No Items" dead-end rather than an endless stack of error pages.
|
|
108
|
-
.setIsBrowsable(false)
|
|
109
|
-
.setIsPlayable(false)
|
|
110
|
-
.build()
|
|
111
|
-
)
|
|
112
|
-
.build()
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Builds the gate tile from a per-request chrome: while a gate is active, tabs stay visible but a
|
|
116
|
-
* gated browse/search level serves this single tile (see the gate checks in [onGetChildren] /
|
|
117
|
-
* [onGetSearchResult]). Same shape as the error tiles — non-browsable, non-playable — and
|
|
118
|
-
* deliberately NOT accompanied by a SessionError: a gate is deliberate app state, not a failure.
|
|
119
|
-
* The message renders as the tile's subtitle (newlines collapse to spaces — list rows are
|
|
120
|
-
* single-line).
|
|
121
|
-
*/
|
|
122
100
|
/**
|
|
123
101
|
* Wraps a raw external-search query string into the structured [SearchParams] the gate request
|
|
124
102
|
* carries. The car search surfaces only give a free-text query, so the other fields stay null —
|
|
@@ -136,40 +114,34 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
136
114
|
reference = MediaReference.UNKNOWN,
|
|
137
115
|
)
|
|
138
116
|
|
|
139
|
-
private fun createGateMediaItem(gate: Gate): MediaItem =
|
|
140
|
-
createErrorMediaItem(
|
|
141
|
-
mediaId = BrowserPathHelper.GATE_PATH,
|
|
142
|
-
title = gate.title,
|
|
143
|
-
subtitle = gate.message?.replace('\n', ' ') ?: "",
|
|
144
|
-
)
|
|
145
|
-
|
|
146
117
|
/**
|
|
147
|
-
* Builds
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
118
|
+
* Builds the gate tile from a per-request chrome: while a gate is active, tabs stay visible but a
|
|
119
|
+
* gated browse/search level serves this single tile (see the gate checks in [onGetChildren] /
|
|
120
|
+
* [onGetSearchResult]). Same shape as the error tiles — non-browsable, non-playable — and
|
|
121
|
+
* deliberately NOT accompanied by a SessionError: a gate is deliberate app state, not a failure.
|
|
151
122
|
*/
|
|
152
|
-
private fun
|
|
123
|
+
private fun createGateMediaItem(gate: Gate): MediaItem =
|
|
153
124
|
createErrorMediaItem(
|
|
154
|
-
|
|
155
|
-
title
|
|
156
|
-
subtitle =
|
|
157
|
-
player.context.getString(com.audiobrowser.R.string.audio_browser_browse_error_subtitle),
|
|
125
|
+
BrowserPathHelper.GATE_PATH,
|
|
126
|
+
FormattedNavigationError(gate.title, gate.message),
|
|
158
127
|
)
|
|
159
128
|
|
|
160
129
|
fun updateMediaSession(
|
|
161
130
|
mediaSession: MediaSession,
|
|
162
131
|
capabilities: PlayerCapabilities,
|
|
163
|
-
|
|
132
|
+
remoteButtonLayout: RemoteButtonLayout?,
|
|
164
133
|
searchAvailable: Boolean,
|
|
134
|
+
forwardJumpInterval: Double,
|
|
135
|
+
backwardJumpInterval: Double,
|
|
165
136
|
) {
|
|
166
|
-
// Store as MediaLibrarySession for notifyChildrenChanged support
|
|
167
137
|
this.mediaLibrarySession = mediaSession as? MediaLibraryService.MediaLibrarySession
|
|
168
138
|
commandManager.updateMediaSession(
|
|
169
139
|
mediaSession,
|
|
170
140
|
capabilities,
|
|
171
|
-
|
|
141
|
+
remoteButtonLayout,
|
|
172
142
|
searchAvailable,
|
|
143
|
+
forwardJumpInterval,
|
|
144
|
+
backwardJumpInterval,
|
|
173
145
|
)
|
|
174
146
|
}
|
|
175
147
|
|
|
@@ -181,18 +153,34 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
181
153
|
return commandManager.buildConnectionResult(session)
|
|
182
154
|
}
|
|
183
155
|
|
|
156
|
+
override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) {
|
|
157
|
+
Timber.Forest.d("MediaSession disconnect: ${controller.packageName}")
|
|
158
|
+
// A controller that leaves without unsubscribing (Android Auto's normal exit) would otherwise
|
|
159
|
+
// be notified — and held — forever.
|
|
160
|
+
offlineSearches.forget(controller)
|
|
161
|
+
synchronized(this) {
|
|
162
|
+
parentIdSubscriptions.values.forEach { it.remove(controller) }
|
|
163
|
+
parentIdSubscriptions.entries.removeAll { (_, browsers) -> browsers.isEmpty() }
|
|
164
|
+
}
|
|
165
|
+
super.onDisconnected(session, controller)
|
|
166
|
+
}
|
|
167
|
+
|
|
184
168
|
override fun onCustomCommand(
|
|
185
169
|
session: MediaSession,
|
|
186
170
|
controller: MediaSession.ControllerInfo,
|
|
187
171
|
command: SessionCommand,
|
|
188
172
|
args: Bundle,
|
|
189
173
|
): ListenableFuture<SessionResult> {
|
|
190
|
-
//
|
|
174
|
+
// A success here makes the controller flip its heart optimistically, so a no-op (no current
|
|
175
|
+
// track) must report INVALID_STATE instead of success-then-revert.
|
|
191
176
|
if (command.customAction == MediaSessionCommandManager.CUSTOM_ACTION_FAVORITE) {
|
|
192
|
-
val
|
|
193
|
-
Timber.d("Favorite button tapped -
|
|
194
|
-
|
|
195
|
-
|
|
177
|
+
val applied = player.toggleActiveTrackFavorited()
|
|
178
|
+
Timber.d("Favorite button tapped - toggle applied=$applied")
|
|
179
|
+
return Futures.immediateFuture(
|
|
180
|
+
SessionResult(
|
|
181
|
+
if (applied) SessionResult.RESULT_SUCCESS else SessionResult.RESULT_ERROR_INVALID_STATE
|
|
182
|
+
)
|
|
183
|
+
)
|
|
196
184
|
}
|
|
197
185
|
|
|
198
186
|
if (commandManager.handleCustomCommand(command, player)) {
|
|
@@ -208,8 +196,16 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
208
196
|
): ListenableFuture<SessionResult> {
|
|
209
197
|
// A heart rating from a controller (e.g. Google Assistant "I like this") toggles the
|
|
210
198
|
// now-playing favorite. setActiveTrackFavorited fires onFavoriteChanged so the consumer
|
|
211
|
-
// persists it — the same path as the notification / CarPlay heart button.
|
|
212
|
-
|
|
199
|
+
// persists it — the same path as the notification / CarPlay heart button. Report an honest
|
|
200
|
+
// result (see onCustomCommand): INVALID_STATE when there is no current track to favorite.
|
|
201
|
+
RatingFavorites.favoritedFor(rating)?.let { favorited ->
|
|
202
|
+
val applied = player.setActiveTrackFavorited(favorited)
|
|
203
|
+
return Futures.immediateFuture(
|
|
204
|
+
SessionResult(
|
|
205
|
+
if (applied) SessionResult.RESULT_SUCCESS else SessionResult.RESULT_ERROR_INVALID_STATE
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
}
|
|
213
209
|
return super.onSetRating(session, controller, rating)
|
|
214
210
|
}
|
|
215
211
|
|
|
@@ -219,13 +215,6 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
219
215
|
browser: MediaSession.ControllerInfo,
|
|
220
216
|
params: MediaLibraryService.LibraryParams?,
|
|
221
217
|
): ListenableFuture<LibraryResult<MediaItem>> {
|
|
222
|
-
// Extract artwork size hint from root hints (e.g., from Android Auto)
|
|
223
|
-
// TODO: Also consider these other root hints in the future:
|
|
224
|
-
// - KEY_ROOT_HINT_MEDIA_HOST_VERSION
|
|
225
|
-
// - KEY_ROOT_HINT_MEDIA_SESSION_API
|
|
226
|
-
// - BROWSER_ROOT_HINTS_KEY_CUSTOM_BROWSER_ACTION_LIMIT
|
|
227
|
-
// - BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT
|
|
228
|
-
// - KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED
|
|
229
218
|
params?.extras?.getInt(MediaConstants.EXTRAS_KEY_MEDIA_ART_SIZE_PIXELS, 0)?.let { size ->
|
|
230
219
|
if (size > 0) {
|
|
231
220
|
artworkSizeHintPixels = size
|
|
@@ -234,13 +223,11 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
234
223
|
}
|
|
235
224
|
|
|
236
225
|
if (params?.isRecent == true) {
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
// an error of type 'RESULT_ERROR_NOT_SUPPORTED' for a `params.isRecent` request. See
|
|
243
|
-
// https://github.com/androidx/media/issues/355
|
|
226
|
+
// Playback resumption is served by media3 itself: it intercepts System UI's EXTRA_RECENT
|
|
227
|
+
// root request internally and answers through onPlaybackResumption, so that request never
|
|
228
|
+
// reaches this callback. Any other browser asking for a recent root gets
|
|
229
|
+
// ERROR_NOT_SUPPORTED — there is no browsable recents tree; recents are ordinary consumer
|
|
230
|
+
// content (a tab or route).
|
|
244
231
|
return Futures.immediateFuture(LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED))
|
|
245
232
|
}
|
|
246
233
|
|
|
@@ -267,10 +254,9 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
267
254
|
params: MediaLibraryService.LibraryParams?,
|
|
268
255
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
|
269
256
|
Timber.d(
|
|
270
|
-
"onGetChildren: {parentId: $parentId, page: $page, pageSize: $pageSize,
|
|
257
|
+
"onGetChildren: {parentId: $parentId, page: $page, pageSize: $pageSize, isSpecialPath: ${BrowserPathHelper.isSpecialPath(parentId)} }"
|
|
271
258
|
)
|
|
272
259
|
return scope.future {
|
|
273
|
-
// Wait for browser to be registered if it's not available yet
|
|
274
260
|
val audioBrowser =
|
|
275
261
|
player.awaitBrowser().also { Timber.d("Browser ready, proceeding with onGetChildren") }
|
|
276
262
|
val browserManager = audioBrowser.browserManager
|
|
@@ -297,54 +283,56 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
297
283
|
}
|
|
298
284
|
}
|
|
299
285
|
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
// notifyChildrenChanged (e.g. when connectivity returns) without any user action.
|
|
306
|
-
if (parentId == BrowserPathHelper.OFFLINE_PATH || parentId == BrowserPathHelper.ERROR_PATH) {
|
|
286
|
+
// A drill-in on the error / offline / empty tile returns nothing (see isDeadEndPath). For
|
|
287
|
+
// the offline tile, re-send the alert when still offline so a tap surfaces the explanation
|
|
288
|
+
// again — but only then, since these paths are also re-queried by notifyChildrenChanged
|
|
289
|
+
// (e.g. when connectivity returns) without any user action.
|
|
290
|
+
if (BrowserPathHelper.isDeadEndPath(parentId)) {
|
|
307
291
|
if (parentId == BrowserPathHelper.OFFLINE_PATH && !player.networkMonitor.isOnline.value) {
|
|
308
292
|
sendBrowseError(session, browser, offline = true)
|
|
309
293
|
}
|
|
310
294
|
return@future LibraryResult.ofItemList(ImmutableList.of<MediaItem>(), params)
|
|
311
295
|
}
|
|
312
296
|
|
|
313
|
-
// Show offline error when offline:
|
|
314
297
|
if (
|
|
315
298
|
!player.networkMonitor.isOnline.value && browserManager.config.androidControllerOfflineError
|
|
316
299
|
) {
|
|
317
300
|
Timber.w("Network offline - returning error message for: $parentId")
|
|
318
301
|
sendBrowseError(session, browser, offline = true)
|
|
319
|
-
return@future LibraryResult.ofItemList(
|
|
302
|
+
return@future LibraryResult.ofItemList(
|
|
303
|
+
ImmutableList.of(
|
|
304
|
+
createOfflineMediaItem(audioBrowser.resolveFormattedError(offlineError(), parentId))
|
|
305
|
+
),
|
|
306
|
+
params,
|
|
307
|
+
)
|
|
320
308
|
}
|
|
321
309
|
|
|
322
310
|
try {
|
|
323
311
|
val children =
|
|
324
|
-
if (parentId == BrowserPathHelper.
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// Return tabs as root children (limited to 4 for automotive platform compatibility)
|
|
329
|
-
// TODO: Check what Android Auto does with empty tabs list - may need to return error?
|
|
330
|
-
val tabs = browserManager.queryTabs()
|
|
312
|
+
if (parentId == BrowserPathHelper.ROOT_PATH) {
|
|
313
|
+
// Disabled tabs hide (Track.disabled) — filtered before the four-tab cap so an
|
|
314
|
+
// unavailable tab never costs an available one its slot.
|
|
315
|
+
val tabs = browserManager.queryTabs().filterNot { it.disabled == true }
|
|
331
316
|
if (tabs.size > 4) {
|
|
332
317
|
Timber.w(
|
|
333
318
|
"Root has ${tabs.size} tabs; dropping ${tabs.size - 4} (Android Auto root limit)"
|
|
334
319
|
)
|
|
335
320
|
}
|
|
336
|
-
|
|
321
|
+
toFlatMediaItems(tabs.take(4))
|
|
337
322
|
} else {
|
|
338
|
-
//
|
|
323
|
+
// Section styles arrive page-folded (`section ?? page`, ADR 0011).
|
|
339
324
|
val resolvedTrack = browserManager.resolve(parentId)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
325
|
+
val sections =
|
|
326
|
+
resolvedTrack.styleResolvedSections()
|
|
327
|
+
?: throw IllegalStateException("Expected browsed ResolvedTrack to have sections")
|
|
328
|
+
browserManager.warnIfGridPageLacksPromise(parentId, sections)
|
|
329
|
+
// A page with no children resolved fine, so it raises no SessionError and no event —
|
|
330
|
+
// notifyChildrenChanged re-queries these paths without any user action. It serves the
|
|
331
|
+
// app's path-aware `empty-content` copy as one inert tile (ADR 0001) instead of
|
|
332
|
+
// Android Auto's bare "No items".
|
|
333
|
+
browseLevelItems(toMediaItems(sections)) {
|
|
334
|
+
audioBrowser.resolveFormattedError(emptyContentError(), parentId)
|
|
335
|
+
}
|
|
348
336
|
}
|
|
349
337
|
|
|
350
338
|
LibraryResult.ofItemList(ImmutableList.copyOf(children.paginate(page, pageSize)), params)
|
|
@@ -353,18 +341,17 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
353
341
|
// error tile. awaitBrowser's TimeoutCancellationException IS a real failure, keep that.
|
|
354
342
|
if (e is CancellationException && e !is TimeoutCancellationException) throw e
|
|
355
343
|
Timber.e(e, "Error getting children for parentId: $parentId")
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
// Auto. See https://github.com/androidx/media/issues/2901
|
|
359
|
-
//
|
|
360
|
-
// Verified on a head unit (2026-06): the AA browse list NEVER renders error text — not for
|
|
361
|
-
// transient sendError, sticky non-fatal replication, or fatal replication
|
|
362
|
-
// (setLibraryErrorReplicationMode). Fatal replication does show the message on the
|
|
363
|
-
// playback screen, but presents the session as STATE_ERROR with no actions, hiding the
|
|
364
|
-
// now-playing item and transport controls — not worth it. Tiles remain the only in-browse
|
|
365
|
-
// signal we control.
|
|
344
|
+
// A tile rather than a bare ofError() — see createErrorMediaItem and
|
|
345
|
+
// https://github.com/androidx/media/issues/2901
|
|
366
346
|
sendBrowseError(session, browser, offline = false)
|
|
367
|
-
LibraryResult.ofItemList(
|
|
347
|
+
LibraryResult.ofItemList(
|
|
348
|
+
ImmutableList.of(
|
|
349
|
+
createBrowseErrorMediaItem(
|
|
350
|
+
audioBrowser.resolveFormattedError(navigationErrorFor(e), parentId)
|
|
351
|
+
)
|
|
352
|
+
),
|
|
353
|
+
params,
|
|
354
|
+
)
|
|
368
355
|
}
|
|
369
356
|
}
|
|
370
357
|
}
|
|
@@ -375,8 +362,8 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
375
362
|
* Media3 transiently attaches the error code/message to the platform session's playback state
|
|
376
363
|
* without entering STATE_ERROR.
|
|
377
364
|
*
|
|
378
|
-
* NOTE: current Android Auto renders
|
|
379
|
-
*
|
|
365
|
+
* NOTE: current Android Auto renders nothing for this — its transient state is cleared
|
|
366
|
+
* microseconds after being set (see [createErrorMediaItem], androidx/media#2901). It is kept
|
|
380
367
|
* because it is the correct Media3-API error signal, costs nothing, and becomes user-visible if
|
|
381
368
|
* Android Auto moves to consuming SessionError via the Media3 controller API.
|
|
382
369
|
*
|
|
@@ -398,19 +385,45 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
398
385
|
}
|
|
399
386
|
|
|
400
387
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
388
|
+
* Flattens a page's sections to MediaItems for browse delivery — the Media3 boundary where
|
|
389
|
+
* sections die (ADR 0010): the MediaBrowser protocol has no section node, so each child is
|
|
390
|
+
* stamped with its owning section's title/style hints. A section with a `path` gains a browsable
|
|
391
|
+
* "view more" row under the same header — at every `display`, because no Android Auto header is
|
|
392
|
+
* tappable at any of them, so the row is the section's only "view all" affordance here. Http(s)
|
|
393
|
+
* artwork routes through the content:// provider so Android Auto can load it via the
|
|
394
|
+
* ArtworkContentProvider.
|
|
405
395
|
*/
|
|
406
|
-
private fun toMediaItems(
|
|
396
|
+
private suspend fun toMediaItems(sections: List<Section>): List<MediaItem> {
|
|
407
397
|
val registry = player.browseArtworkRegistry
|
|
408
398
|
val authority = com.audiobrowser.util.ArtworkUris.authorityFor(player.context.packageName)
|
|
409
|
-
return
|
|
410
|
-
|
|
399
|
+
return sections.flatMap { section ->
|
|
400
|
+
// Android Auto has no disabled affordance, so an unavailable track hides
|
|
401
|
+
// — never a normal-looking dead row (Track.disabled's rendering ladder).
|
|
402
|
+
val children = section.children.filter { it.disabled != true }
|
|
403
|
+
val items = children.map { TrackFactory.toBrowseMediaItem(it, registry, authority, section) }
|
|
404
|
+
// No "view more" under an empty section — CarPlay skips empty sections
|
|
405
|
+
// outright, and a lone navigation tile under a header is a dead end.
|
|
406
|
+
if (section.path == null || children.isEmpty()) return@flatMap items
|
|
407
|
+
// The browser is already up at every call site, so awaiting it here is a field read, and
|
|
408
|
+
// viewMoreTitle is memoized per content generation — both are free after the first row.
|
|
409
|
+
val title = player.awaitBrowser().browserManager.viewMoreTitle()
|
|
410
|
+
items +
|
|
411
|
+
TrackFactory.toBrowseMediaItem(
|
|
412
|
+
TrackFactory.navigationTrack(section, title),
|
|
413
|
+
registry,
|
|
414
|
+
authority,
|
|
415
|
+
section,
|
|
416
|
+
)
|
|
411
417
|
}
|
|
412
418
|
}
|
|
413
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Flat lists (tabs, search results) convert as one untitled list section — which contributes no
|
|
422
|
+
* group or style hints — so exactly one Track→MediaItem path exists at the Media3 boundary.
|
|
423
|
+
*/
|
|
424
|
+
private suspend fun toFlatMediaItems(tracks: List<Track>): List<MediaItem> =
|
|
425
|
+
toMediaItems(listOf(untitledSection(tracks.toTypedArray())))
|
|
426
|
+
|
|
414
427
|
override fun onGetItem(
|
|
415
428
|
session: MediaLibraryService.MediaLibrarySession,
|
|
416
429
|
browser: MediaSession.ControllerInfo,
|
|
@@ -418,15 +431,56 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
418
431
|
): ListenableFuture<LibraryResult<MediaItem>> {
|
|
419
432
|
Timber.Forest.d("onGetItem: ${browser.packageName}, mediaId = $mediaId")
|
|
420
433
|
return scope.future {
|
|
421
|
-
//
|
|
434
|
+
// Only the root answers ahead of awaitBrowser: it needs no browser. Every tile sentinel
|
|
435
|
+
// below does — for the formatter hop, and the gate one for gateDecision.
|
|
436
|
+
if (mediaId == BrowserPathHelper.ROOT_PATH) {
|
|
437
|
+
return@future LibraryResult.ofItem(
|
|
438
|
+
MediaItem.Builder()
|
|
439
|
+
.setMediaId(mediaId)
|
|
440
|
+
.setMediaMetadata(
|
|
441
|
+
MediaMetadata.Builder().setIsBrowsable(true).setIsPlayable(false).build()
|
|
442
|
+
)
|
|
443
|
+
.build(),
|
|
444
|
+
null,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
422
448
|
if (mediaId == BrowserPathHelper.OFFLINE_PATH) {
|
|
423
|
-
return@future LibraryResult.ofItem(
|
|
449
|
+
return@future LibraryResult.ofItem(
|
|
450
|
+
createOfflineMediaItem(
|
|
451
|
+
player
|
|
452
|
+
.awaitBrowser()
|
|
453
|
+
.resolveFormattedError(offlineError(), BrowserPathHelper.OFFLINE_PATH)
|
|
454
|
+
),
|
|
455
|
+
null,
|
|
456
|
+
)
|
|
424
457
|
}
|
|
425
458
|
|
|
426
|
-
// Return the error tile as a non-browsable item so tapping it can't re-browse into a
|
|
427
|
-
// failing path (which would push another error page onto the stack).
|
|
428
459
|
if (mediaId == BrowserPathHelper.ERROR_PATH) {
|
|
429
|
-
|
|
460
|
+
// The exception that produced the tile is gone by now — the sentinel is all the controller
|
|
461
|
+
// hands back — so the copy formats for the sentinel under the generic failure code.
|
|
462
|
+
return@future LibraryResult.ofItem(
|
|
463
|
+
createBrowseErrorMediaItem(
|
|
464
|
+
player
|
|
465
|
+
.awaitBrowser()
|
|
466
|
+
.resolveFormattedError(browseFailureError(), BrowserPathHelper.ERROR_PATH)
|
|
467
|
+
),
|
|
468
|
+
null,
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (mediaId == BrowserPathHelper.EMPTY_PATH) {
|
|
473
|
+
// A controller re-reading the tile it was served. The page's path is gone by now — the
|
|
474
|
+
// sentinel is all it hands back — so the copy formats for the sentinel and lands on the
|
|
475
|
+
// app's generic empty wording, or the default.
|
|
476
|
+
return@future LibraryResult.ofItem(
|
|
477
|
+
createEmptyMediaItem(
|
|
478
|
+
player
|
|
479
|
+
.awaitBrowser()
|
|
480
|
+
.resolveFormattedError(emptyContentError(), BrowserPathHelper.EMPTY_PATH)
|
|
481
|
+
),
|
|
482
|
+
null,
|
|
483
|
+
)
|
|
430
484
|
}
|
|
431
485
|
|
|
432
486
|
if (mediaId == BrowserPathHelper.GATE_PATH) {
|
|
@@ -447,22 +501,9 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
447
501
|
return@future LibraryResult.ofItem(createGateMediaItem(outcome.chrome!!), null)
|
|
448
502
|
}
|
|
449
503
|
|
|
450
|
-
if (mediaId == BrowserPathHelper.ROOT_PATH || mediaId == BrowserPathHelper.RECENT_PATH) {
|
|
451
|
-
return@future LibraryResult.ofItem(
|
|
452
|
-
MediaItem.Builder()
|
|
453
|
-
.setMediaId(mediaId)
|
|
454
|
-
.setMediaMetadata(
|
|
455
|
-
MediaMetadata.Builder().setIsBrowsable(true).setIsPlayable(false).build()
|
|
456
|
-
)
|
|
457
|
-
.build(),
|
|
458
|
-
null,
|
|
459
|
-
)
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Wait for browser to be registered if it's not available yet
|
|
463
504
|
val browserManager = player.awaitBrowser().browserManager
|
|
464
505
|
|
|
465
|
-
// Serve tracks from the track cache first (keyed by
|
|
506
|
+
// Serve tracks from the track cache first (keyed by path and src). Besides avoiding an HTTP
|
|
466
507
|
// resolve, this keeps item identity correct for contextual mediaIds: resolve() strips
|
|
467
508
|
// __trackId and would return the *parent container's* metadata as the item.
|
|
468
509
|
val browseAuthority =
|
|
@@ -534,9 +575,6 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
534
575
|
}
|
|
535
576
|
|
|
536
577
|
/**
|
|
537
|
-
* Notifies all subscribed controllers to refresh their content. Called internally when network
|
|
538
|
-
* state changes to refresh all subscribed paths.
|
|
539
|
-
*
|
|
540
578
|
* Safe to call from any thread: snapshots the subscribed paths and dispatches the session calls
|
|
541
579
|
* to the main (Media3 application) thread.
|
|
542
580
|
*/
|
|
@@ -549,10 +587,8 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
549
587
|
}
|
|
550
588
|
|
|
551
589
|
/**
|
|
552
|
-
* Notifies external controllers that content at the given path has changed
|
|
553
|
-
* subscribed to
|
|
554
|
-
*
|
|
555
|
-
* @param path The path where content has changed
|
|
590
|
+
* Notifies external controllers that content at the given path has changed, so controllers
|
|
591
|
+
* subscribed to it refresh their UI. Safe to call from any thread.
|
|
556
592
|
*/
|
|
557
593
|
fun notifyContentChanged(path: String) {
|
|
558
594
|
Timber.d("Notifying content changed for path: $path")
|
|
@@ -570,10 +606,6 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
570
606
|
notifySubscribedChildrenChanged()
|
|
571
607
|
}
|
|
572
608
|
|
|
573
|
-
/**
|
|
574
|
-
* Called when the browser becomes available after a cold start. Notifies all subscribed
|
|
575
|
-
* controllers to refresh their content.
|
|
576
|
-
*/
|
|
577
609
|
fun notifyBrowserReady() {
|
|
578
610
|
Timber.d("Browser ready - notifying all subscribed paths")
|
|
579
611
|
notifySubscribedChildrenChanged()
|
|
@@ -592,8 +624,6 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
592
624
|
): ListenableFuture<LibraryResult<Void>> {
|
|
593
625
|
Timber.d("onSearch: ${browser.packageName}, query = $query")
|
|
594
626
|
return scope.future {
|
|
595
|
-
// Wait for browser registration like onGetChildren does, so a cold-start voice search
|
|
596
|
-
// doesn't fail before JS has configured the browser.
|
|
597
627
|
val audioBrowser =
|
|
598
628
|
try {
|
|
599
629
|
player.awaitBrowser()
|
|
@@ -615,28 +645,83 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
615
645
|
return@future LibraryResult.ofVoid()
|
|
616
646
|
}
|
|
617
647
|
|
|
618
|
-
//
|
|
648
|
+
// Before the offline branch inside announceSearch: with no search source configured the
|
|
649
|
+
// answer is not-supported, online or not.
|
|
619
650
|
if (!browserManager.config.hasSearch) {
|
|
620
651
|
Timber.w("Search requested but no search source configured")
|
|
621
652
|
return@future LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED)
|
|
622
653
|
}
|
|
623
654
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
Timber.d("Search completed: $resultCount results for query '$query'")
|
|
655
|
+
announceSearch(session, browser, browserManager, query, params)
|
|
656
|
+
LibraryResult.ofVoid()
|
|
657
|
+
}
|
|
658
|
+
}
|
|
630
659
|
|
|
631
|
-
|
|
632
|
-
|
|
660
|
+
/**
|
|
661
|
+
* Runs [query] and tells [browser] how many items [onGetSearchResult] will serve for it: the
|
|
662
|
+
* search's own hits, or the single tile standing in for them — a controller told zero never asks
|
|
663
|
+
* for items. Offline keeps the query for [reannounceOfflineSearches]; every other outcome drops
|
|
664
|
+
* it, so one controller holds at most its latest unanswered search.
|
|
665
|
+
*/
|
|
666
|
+
private suspend fun announceSearch(
|
|
667
|
+
session: MediaLibraryService.MediaLibrarySession,
|
|
668
|
+
browser: MediaSession.ControllerInfo,
|
|
669
|
+
browserManager: BrowserManager,
|
|
670
|
+
query: String,
|
|
671
|
+
params: MediaLibraryService.LibraryParams?,
|
|
672
|
+
) {
|
|
673
|
+
if (
|
|
674
|
+
!player.networkMonitor.isOnline.value && browserManager.config.androidControllerOfflineError
|
|
675
|
+
) {
|
|
676
|
+
Timber.w("Network offline - announcing the offline tile for query: $query")
|
|
677
|
+
offlineSearches.record(browser, query, params)
|
|
678
|
+
session.notifySearchResultChanged(browser, query, 1, params)
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
offlineSearches.forget(browser)
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
// Caches the result set under the query, which onGetSearchResult reads back through
|
|
685
|
+
// getCachedSearchResults.
|
|
686
|
+
val searchResults = browserManager.search(query)
|
|
687
|
+
failedSearch.clear()
|
|
688
|
+
val resultCount = announcedSearchCount(searchResults.normalizedSections)
|
|
689
|
+
|
|
690
|
+
Timber.d("Search announced $resultCount item(s) for query '$query'")
|
|
691
|
+
|
|
692
|
+
session.notifySearchResultChanged(browser, query, resultCount, params)
|
|
693
|
+
} catch (e: Exception) {
|
|
694
|
+
if (e is CancellationException && e !is TimeoutCancellationException) throw e
|
|
695
|
+
Timber.e(e, "Error during search for query: $query")
|
|
696
|
+
// A failed search announces its one item — the browse-error tile onGetSearchResult
|
|
697
|
+
// serves — rather than an ofError the browse list renders as nothing at all. Without
|
|
698
|
+
// the slot the failure is indistinguishable from an empty page by the time that call
|
|
699
|
+
// arrives, and a server error would read as the app's "no results" copy.
|
|
700
|
+
failedSearch.record(query, navigationErrorFor(e))
|
|
701
|
+
session.notifySearchResultChanged(browser, query, 1, params)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
633
704
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
705
|
+
/**
|
|
706
|
+
* Re-runs every search that was served the offline tile, now that the network is back. Announcing
|
|
707
|
+
* what the fresh search found — rather than a bare "one item" — keeps the announced count equal
|
|
708
|
+
* to what [onGetSearchResult] will then serve from the cache this fills.
|
|
709
|
+
*/
|
|
710
|
+
private suspend fun reannounceOfflineSearches() {
|
|
711
|
+
val pending = offlineSearches.drain()
|
|
712
|
+
if (pending.isEmpty()) return
|
|
713
|
+
val session = mediaLibrarySession ?: return
|
|
714
|
+
val browserManager =
|
|
715
|
+
try {
|
|
716
|
+
player.awaitBrowser().browserManager
|
|
717
|
+
} catch (e: TimeoutCancellationException) {
|
|
718
|
+
Timber.w("Timed out waiting for browser - offline searches not re-announced")
|
|
719
|
+
return
|
|
639
720
|
}
|
|
721
|
+
|
|
722
|
+
pending.forEach { search ->
|
|
723
|
+
Timber.d("Network back - re-running search: ${search.query}")
|
|
724
|
+
announceSearch(session, search.browser, browserManager, search.query, search.params)
|
|
640
725
|
}
|
|
641
726
|
}
|
|
642
727
|
|
|
@@ -674,26 +759,57 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
674
759
|
)
|
|
675
760
|
}
|
|
676
761
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
762
|
+
// Checked before the failure slot, as in onGetChildren: a search that never reached the
|
|
763
|
+
// network is not a server failure, and connectivity returning is what fixes it.
|
|
764
|
+
if (
|
|
765
|
+
!player.networkMonitor.isOnline.value && browserManager.config.androidControllerOfflineError
|
|
766
|
+
) {
|
|
767
|
+
Timber.w("Network offline - returning the offline tile for query: $query")
|
|
768
|
+
sendBrowseError(session, browser, offline = true)
|
|
769
|
+
return@future LibraryResult.ofItemList(
|
|
770
|
+
ImmutableList.of(
|
|
771
|
+
createOfflineMediaItem(
|
|
772
|
+
audioBrowser.resolveFormattedError(
|
|
773
|
+
offlineError(),
|
|
774
|
+
BrowserPathHelper.createSearchPath(query),
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
),
|
|
778
|
+
params,
|
|
779
|
+
)
|
|
780
|
+
}
|
|
688
781
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
782
|
+
// The search itself failed, so this is a failure, not an empty page — same tile and same
|
|
783
|
+
// SessionError the browse catch sends, worded for the error its exception mapped to.
|
|
784
|
+
failedSearch.errorFor(query)?.let { error ->
|
|
785
|
+
sendBrowseError(session, browser, offline = false)
|
|
786
|
+
return@future LibraryResult.ofItemList(
|
|
787
|
+
ImmutableList.of(
|
|
788
|
+
createBrowseErrorMediaItem(
|
|
789
|
+
audioBrowser.resolveFormattedError(error, BrowserPathHelper.createSearchPath(query))
|
|
790
|
+
)
|
|
791
|
+
),
|
|
792
|
+
params,
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
try {
|
|
797
|
+
val tracks = browserManager.getCachedSearchResults(query)
|
|
798
|
+
if (tracks == null) Timber.w("No cached search results for query: $query")
|
|
799
|
+
// A search with nothing servable — no hits, every hit disabled, or nothing cached under
|
|
800
|
+
// this query — serves the app's copy for the search path instead of the car's bare "No
|
|
801
|
+
// items" (ADR 0001). Something must always come back: onSearch announced one item.
|
|
802
|
+
val mediaItems =
|
|
803
|
+
browseLevelItems(toFlatMediaItems(tracks?.toList().orEmpty())) {
|
|
804
|
+
audioBrowser.resolveFormattedError(
|
|
805
|
+
emptyContentError(),
|
|
806
|
+
BrowserPathHelper.createSearchPath(query),
|
|
807
|
+
)
|
|
696
808
|
}
|
|
809
|
+
|
|
810
|
+
val paginatedItems = mediaItems.paginate(page, pageSize)
|
|
811
|
+
Timber.d("Returning ${paginatedItems.size} search results")
|
|
812
|
+
LibraryResult.ofItemList(ImmutableList.copyOf(paginatedItems), params)
|
|
697
813
|
} catch (e: Exception) {
|
|
698
814
|
if (e is CancellationException && e !is TimeoutCancellationException) throw e
|
|
699
815
|
Timber.e(e, "Error getting search results for query: $query")
|
|
@@ -722,8 +838,9 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
722
838
|
return scope.future {
|
|
723
839
|
val audioBrowser = player.awaitBrowser()
|
|
724
840
|
|
|
725
|
-
//
|
|
726
|
-
//
|
|
841
|
+
// The "leave playback alone" answer for handleTrackLoad: returning the current state
|
|
842
|
+
// unchanged is how Media3 is told to do nothing. ExoPlayer is main-thread confined and this
|
|
843
|
+
// future runs on IO, so the reads hop to Main.
|
|
727
844
|
suspend fun currentPlayerState(): MediaSession.MediaItemsWithStartPosition =
|
|
728
845
|
withContext(Dispatchers.Main) {
|
|
729
846
|
val currentItems = player.tracks.map { TrackFactory.toMedia3(it) }
|
|
@@ -733,26 +850,26 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
733
850
|
|
|
734
851
|
val browserManager = audioBrowser.browserManager
|
|
735
852
|
|
|
736
|
-
// A single tapped item resolves to the contextual
|
|
853
|
+
// A single tapped item resolves to the contextual path of the list it was
|
|
737
854
|
// tapped in: directly for contextual mediaIds, via the track cache for
|
|
738
|
-
// stable-id mediaIds (see BrowserManager.
|
|
855
|
+
// stable-id mediaIds (see BrowserManager.contextualPathFor). A search
|
|
739
856
|
// selection is not a list tap — its queue comes from the search results
|
|
740
857
|
// (resolveMediaItemsForPlayback), never from a browsed container.
|
|
741
|
-
val
|
|
858
|
+
val singleContextualPath =
|
|
742
859
|
mediaItems
|
|
743
860
|
.singleOrNull()
|
|
744
861
|
?.takeIf { it.requestMetadata.searchQuery == null }
|
|
745
|
-
?.let { browserManager.
|
|
862
|
+
?.let { browserManager.contextualPathFor(it.mediaId) }
|
|
746
863
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
val
|
|
750
|
-
val trackId = BrowserPathHelper.extractTrackId(singleContextualUrl)
|
|
864
|
+
if (singleContextualPath != null) {
|
|
865
|
+
val parentPath = BrowserPathHelper.stripTrackId(singleContextualPath)
|
|
866
|
+
val trackId = BrowserPathHelper.extractTrackId(singleContextualPath)
|
|
751
867
|
|
|
752
|
-
//
|
|
868
|
+
// A tap inside the queue's own source list skips rather than rebuilds (exact path first,
|
|
869
|
+
// identity for index-less paths — see indexOfTappedTrack).
|
|
753
870
|
if (trackId != null && parentPath == player.queueSourcePath) {
|
|
754
871
|
val queueTracks = withContext(Dispatchers.Main) { player.tracks }
|
|
755
|
-
val index = queueTracks.
|
|
872
|
+
val index = queueTracks.indexOfTappedTrack(singleContextualPath, trackId)
|
|
756
873
|
if (index >= 0) {
|
|
757
874
|
Timber.d("Queue already from $parentPath, skipping to index $index")
|
|
758
875
|
val track = queueTracks[index]
|
|
@@ -763,7 +880,6 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
763
880
|
index.toDouble(),
|
|
764
881
|
::currentPlayerState,
|
|
765
882
|
) {
|
|
766
|
-
// Return the existing queue items with the new start index
|
|
767
883
|
val existingItems = queueTracks.map { TrackFactory.toMedia3(it) }
|
|
768
884
|
MediaSession.MediaItemsWithStartPosition(existingItems, index, startPositionMs)
|
|
769
885
|
}
|
|
@@ -784,9 +900,10 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
784
900
|
result.startIndex.toDouble(),
|
|
785
901
|
::currentPlayerState,
|
|
786
902
|
) {
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
903
|
+
// Inside handleTrackLoad's default branch: a consumer that handles the load itself owns
|
|
904
|
+
// the queue, so queueSourcePath must not claim this path on its behalf.
|
|
905
|
+
if (singleContextualPath != null) {
|
|
906
|
+
val parentPath = BrowserPathHelper.stripTrackId(singleContextualPath)
|
|
790
907
|
withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
|
|
791
908
|
}
|
|
792
909
|
result
|
|
@@ -802,8 +919,6 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
802
919
|
* using the browse callback. This enables seamless resumption after app restart with the same
|
|
803
920
|
* player settings (repeat mode, shuffle, playback speed).
|
|
804
921
|
*
|
|
805
|
-
* @param mediaSession The media session
|
|
806
|
-
* @param controller The controller requesting resumption
|
|
807
922
|
* @param isForPlayback True if this should start playback; false if just gathering info for the
|
|
808
923
|
* boot-time resumption notification (no network; local metadata only).
|
|
809
924
|
* @see https://developer.android.com/media/media3/session/background-playback#resumption
|
|
@@ -825,9 +940,17 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
825
940
|
throw IllegalStateException("No playback state to resume")
|
|
826
941
|
}
|
|
827
942
|
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
943
|
+
// The persisted track can carry `disabled` — it became unavailable after
|
|
944
|
+
// it was saved. A disabled track never plays, resumption included
|
|
945
|
+
// (Track.disabled); failing the future refuses without touching the
|
|
946
|
+
// player.
|
|
947
|
+
if (state.track.disabled == true) {
|
|
948
|
+
Timber.w("Refusing resumption of disabled track: ${state.track.title}")
|
|
949
|
+
throw IllegalStateException("Persisted track is disabled")
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Info-gathering returns before the settings restore and the queue expansion below: both
|
|
953
|
+
// touch the live player or the network, and neither is permitted here.
|
|
831
954
|
if (!isForPlayback) {
|
|
832
955
|
Timber.d("Info-gathering resumption (boot-time); returning stored track without expansion")
|
|
833
956
|
return@future MediaSession.MediaItemsWithStartPosition(
|
|
@@ -840,14 +963,12 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
840
963
|
// restore() sets player properties which must happen on main thread
|
|
841
964
|
withContext(Dispatchers.Main) { player.playbackStateStore.restore() }
|
|
842
965
|
|
|
843
|
-
val
|
|
844
|
-
Timber.d("Resuming from
|
|
966
|
+
val path = state.track.path
|
|
967
|
+
Timber.d("Resuming from path=$path, positionMs=${state.positionMs}")
|
|
845
968
|
|
|
846
|
-
// Wait for browser to be available (JS needs to have configured it)
|
|
847
969
|
val browserManager = player.awaitBrowser().browserManager
|
|
848
970
|
|
|
849
|
-
|
|
850
|
-
val expanded = url?.let { browserManager.expandQueueFromContextualUrl(it) }
|
|
971
|
+
val expanded = path?.let { browserManager.expandQueueFromContextualPath(it) }
|
|
851
972
|
|
|
852
973
|
if (expanded != null) {
|
|
853
974
|
val (tracks, selectedIndex) = expanded
|
|
@@ -855,9 +976,8 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
855
976
|
"Restored ${tracks.size} tracks, starting at index $selectedIndex at ${state.positionMs}ms"
|
|
856
977
|
)
|
|
857
978
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
val parentPath = BrowserPathHelper.stripTrackId(url)
|
|
979
|
+
if (BrowserPathHelper.isContextual(path)) {
|
|
980
|
+
val parentPath = BrowserPathHelper.stripTrackId(path)
|
|
861
981
|
withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
|
|
862
982
|
}
|
|
863
983
|
|
|
@@ -867,7 +987,6 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
867
987
|
state.positionMs,
|
|
868
988
|
)
|
|
869
989
|
} else {
|
|
870
|
-
// Fallback: play just the stored track
|
|
871
990
|
Timber.d("Queue expansion failed, using stored track: ${state.track.title}")
|
|
872
991
|
MediaSession.MediaItemsWithStartPosition(
|
|
873
992
|
ImmutableList.of(TrackFactory.toMedia3(state.track)),
|
|
@@ -878,3 +997,163 @@ class MediaSessionCallback(private val player: Player) :
|
|
|
878
997
|
}
|
|
879
998
|
}
|
|
880
999
|
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Builds a non-browsable, non-playable [MediaItem] used to surface an error inside an Android Auto
|
|
1003
|
+
* / AAOS browse list. Rendered as a greyed-out, non-interactive tile, which is the only
|
|
1004
|
+
* side-effect-free way to communicate a browse failure to legacy controllers (Media3 drops
|
|
1005
|
+
* [LibraryResult.ofError] on the legacy browse bridge, leaving an empty "No items" screen).
|
|
1006
|
+
*
|
|
1007
|
+
* Tiles are the only in-browse error signal that exists. Verified on a head unit (2026-06): the
|
|
1008
|
+
* Android Auto browse list renders no error text for a transient [SessionError], for sticky
|
|
1009
|
+
* non-fatal replication, or for fatal replication (setLibraryErrorReplicationMode). Fatal
|
|
1010
|
+
* replication does surface the message on the playback screen, but at the cost of presenting the
|
|
1011
|
+
* session as STATE_ERROR with no actions, hiding the now-playing item and transport controls.
|
|
1012
|
+
*/
|
|
1013
|
+
internal fun createErrorMediaItem(mediaId: String, title: String, subtitle: String): MediaItem =
|
|
1014
|
+
MediaItem.Builder()
|
|
1015
|
+
.setMediaId(mediaId)
|
|
1016
|
+
.setMediaMetadata(
|
|
1017
|
+
MediaMetadata.Builder()
|
|
1018
|
+
.setTitle(title)
|
|
1019
|
+
.setSubtitle(subtitle)
|
|
1020
|
+
// Android Auto ignores these flags and drills into a tapped tile anyway; onGetChildren
|
|
1021
|
+
// returns an empty list for the sentinel paths, so it dead-ends at "No Items" rather
|
|
1022
|
+
// than stacking error pages.
|
|
1023
|
+
.setIsBrowsable(false)
|
|
1024
|
+
.setIsPlayable(false)
|
|
1025
|
+
.build()
|
|
1026
|
+
)
|
|
1027
|
+
.build()
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* The formatted form every tile is built from: the message renders as the subtitle, with newlines
|
|
1031
|
+
* collapsed to spaces — list rows are single-line.
|
|
1032
|
+
*/
|
|
1033
|
+
private fun createErrorMediaItem(mediaId: String, formatted: FormattedNavigationError): MediaItem =
|
|
1034
|
+
createErrorMediaItem(
|
|
1035
|
+
mediaId = mediaId,
|
|
1036
|
+
title = formatted.title,
|
|
1037
|
+
subtitle = formatted.message?.replace('\n', ' ') ?: "",
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Builds the empty tile from the app's formatted `empty-content` copy for the page's path, so an
|
|
1042
|
+
* empty Favorites tab and an empty search can be worded apart (ADR 0001). Same shape as the error
|
|
1043
|
+
* tiles — non-browsable, non-playable — and deliberately NOT accompanied by a SessionError: an
|
|
1044
|
+
* empty page is a successful resolve.
|
|
1045
|
+
*/
|
|
1046
|
+
internal fun createEmptyMediaItem(formatted: FormattedNavigationError): MediaItem =
|
|
1047
|
+
createErrorMediaItem(BrowserPathHelper.EMPTY_PATH, formatted)
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Builds the offline tile from the app's formatted `network-error` copy for the path being served,
|
|
1051
|
+
* so one browse list never mixes the app's locale with the device's.
|
|
1052
|
+
*/
|
|
1053
|
+
internal fun createOfflineMediaItem(formatted: FormattedNavigationError): MediaItem =
|
|
1054
|
+
createErrorMediaItem(BrowserPathHelper.OFFLINE_PATH, formatted)
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Builds the tile a failed browse or search serves, from the formatted copy for the error the
|
|
1058
|
+
* request's exception mapped to. The true offline case is handled separately by the networkMonitor
|
|
1059
|
+
* guards; anything reaching a catch block is an online-but-failed request (e.g. server down, bad
|
|
1060
|
+
* status), so it must NOT be labelled "no internet connection".
|
|
1061
|
+
*/
|
|
1062
|
+
internal fun createBrowseErrorMediaItem(formatted: FormattedNavigationError): MediaItem =
|
|
1063
|
+
createErrorMediaItem(BrowserPathHelper.ERROR_PATH, formatted)
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* What a non-root browse level or a search serves: its own items, or the lone empty tile when it
|
|
1067
|
+
* came back with none. [formatted] is a JS hop, so it runs only when there is nothing to show. The
|
|
1068
|
+
* root is not served through here — an empty tab list is a different condition.
|
|
1069
|
+
*/
|
|
1070
|
+
internal suspend fun browseLevelItems(
|
|
1071
|
+
children: List<MediaItem>,
|
|
1072
|
+
formatted: suspend () -> FormattedNavigationError,
|
|
1073
|
+
): List<MediaItem> = children.ifEmpty { listOf(createEmptyMediaItem(formatted())) }
|
|
1074
|
+
|
|
1075
|
+
/** The navigation error an empty page raises: no message of its own, the path carries the copy. */
|
|
1076
|
+
internal fun emptyContentError(): NavigationError =
|
|
1077
|
+
NavigationError(NavigationErrorType.EMPTY_CONTENT, "", null, null)
|
|
1078
|
+
|
|
1079
|
+
/** The navigation error a browse or search served while offline raises. */
|
|
1080
|
+
internal fun offlineError(): NavigationError =
|
|
1081
|
+
NavigationError(NavigationErrorType.NETWORK_ERROR, "", null, null)
|
|
1082
|
+
|
|
1083
|
+
/** The failure a re-read of the browse-error tile stands on, its own exception long gone. */
|
|
1084
|
+
internal fun browseFailureError(): NavigationError =
|
|
1085
|
+
NavigationError(NavigationErrorType.UNKNOWN_ERROR, "", null, null)
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* The item count [MediaSessionCallback.onSearch] announces for a search that ran. Disabled tracks
|
|
1089
|
+
* hide on Android Auto (Track.disabled), so they never inflate it; and a search with nothing
|
|
1090
|
+
* servable announces one — the empty tile [MediaSessionCallback.onGetSearchResult] serves in their
|
|
1091
|
+
* place — because a controller told zero never asks for items. The gate and failure branches
|
|
1092
|
+
* announce their own single tile by the same rule.
|
|
1093
|
+
*/
|
|
1094
|
+
internal fun announcedSearchCount(sections: List<Section>?): Int =
|
|
1095
|
+
(sections?.sumOf { section -> section.children.count { it.disabled != true } } ?: 0)
|
|
1096
|
+
.coerceAtLeast(1)
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* The search each controller was last served the offline tile for. Media3 addresses a search result
|
|
1100
|
+
* by (controller, query) and keeps no record of its own, and a search has no subscription the way a
|
|
1101
|
+
* browse level does — so without this the offline tile stays on screen until the user searches
|
|
1102
|
+
* again. Only an offline-served query is kept: a search that ran found what it found, and one that
|
|
1103
|
+
* failed did so with a network.
|
|
1104
|
+
*
|
|
1105
|
+
* Written from the callback's IO scope and read from the connectivity observer, so every accessor
|
|
1106
|
+
* is synchronized.
|
|
1107
|
+
*/
|
|
1108
|
+
internal class OfflineSearchRegistry {
|
|
1109
|
+
private val searches = mutableMapOf<MediaSession.ControllerInfo, OfflineSearch>()
|
|
1110
|
+
|
|
1111
|
+
@Synchronized
|
|
1112
|
+
fun record(
|
|
1113
|
+
browser: MediaSession.ControllerInfo,
|
|
1114
|
+
query: String,
|
|
1115
|
+
params: MediaLibraryService.LibraryParams?,
|
|
1116
|
+
) {
|
|
1117
|
+
searches[browser] = OfflineSearch(browser, query, params)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
@Synchronized
|
|
1121
|
+
fun forget(browser: MediaSession.ControllerInfo) {
|
|
1122
|
+
searches.remove(browser)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Takes every record: a re-run that finds the network gone again records its own. */
|
|
1126
|
+
@Synchronized
|
|
1127
|
+
fun drain(): List<OfflineSearch> = searches.values.toList().also { searches.clear() }
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** One controller's unanswered search: everything announcing it again needs. */
|
|
1131
|
+
internal data class OfflineSearch(
|
|
1132
|
+
val browser: MediaSession.ControllerInfo,
|
|
1133
|
+
val query: String,
|
|
1134
|
+
val params: MediaLibraryService.LibraryParams?,
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Remembers the query whose search threw, so [MediaSessionCallback.onGetSearchResult] serves the
|
|
1139
|
+
* browse-error tile instead of the empty one — by then the failure is otherwise indistinguishable
|
|
1140
|
+
* from a page that found nothing. Written and read from the callback's IO scope.
|
|
1141
|
+
*
|
|
1142
|
+
* One slot, matching BrowserManager's single-entry result cache: two failures in a row leave only
|
|
1143
|
+
* the later query recorded, so a late fetch of the earlier one serves the empty tile instead of the
|
|
1144
|
+
* error tile. Acceptable while only the most recent search's results are retrievable at all.
|
|
1145
|
+
*/
|
|
1146
|
+
internal class FailedSearchSlot {
|
|
1147
|
+
@Volatile private var failed: Pair<String, NavigationError>? = null
|
|
1148
|
+
|
|
1149
|
+
fun record(query: String, error: NavigationError) {
|
|
1150
|
+
failed = query to error
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
fun clear() {
|
|
1154
|
+
failed = null
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** The error [query] failed with, or null when it is not the query that failed. */
|
|
1158
|
+
fun errorFor(query: String): NavigationError? = failed?.takeIf { it.first == query }?.second
|
|
1159
|
+
}
|