Native player overhaul: atomic setupPlayer, now-playing/CarPlay & Android Auto, media3 1.10, request-resolver unification#51
Draft
puckey wants to merge 300 commits into
Draft
Native player overhaul: atomic setupPlayer, now-playing/CarPlay & Android Auto, media3 1.10, request-resolver unification#51puckey wants to merge 300 commits into
puckey wants to merge 300 commits into
Conversation
Add JsonTrackRequest Codable struct and request field to JsonTrack so browse JSON containing a request block is decoded and threaded through toNitro() onto Track.request (TrackRequest). Also expose JsonModels to SPM tests (Package.swift sources list + NitroTypeStubs), guarding the full toNitro() path behind #if canImport(NitroModules).
…omise<RequestConfig>)
Widen the callback return types on BrowserConfiguration so consumers can return a value directly instead of always wrapping it in a Promise. Mirrors the existing media.resolve union (RequestConfig | Promise<RequestConfig>). - ts: widen BrowserSourceCallback, RequestConfigTransformer, MediaRequestConfigTransformer, ArtworkRequestConfig.resolve and TabsSourceCallback to T | Promise<T> - codegen: regenerate the variant Func bindings (Promise<variant<T, Promise<T>>>; browseCallback flattens to a 3-arm variant) - ios: unwrap the variant in browseCallback, applyTransform and the media/artwork resolve+transform paths - android: unwrap the variant via .match in browseCallback, RequestConfigBuilder transforms and CoilBitmapLoader resolve+transform
Extends the per-track request override (Track.request) to the Android side and fills the remaining iOS gaps surfaced once codegen regenerated Track / ResolvedTrack with the request field. - android: parse JsonTrackRequest from browse JSON, wrap the request-layered base as MediaRequestConfig, and null out request when persisting state - ios: pass request through JsonResolvedTrack and the CarPlay Track init - ios: add request to the Track / ResolvedTrack copying helpers and the NitroTypeStubs test stub
Many broadcasters serve a leaf-only certificate chain, omitting the intermediate needed to reach a trusted root. iOS and browsers fetch it from the leaf's AIA "CA Issuers" pointer; Android's default trust manager does not, so such streams fail with "Trust anchor for certification path not found". Add a side-effect-free AiaTls.socketFactory() backed by a trust manager that, only after the platform default rejects a chain, fetches the missing intermediate(s) via AIA and re-validates against the system trust anchors. Additive-only, so it can never weaken trust. Pure-Kotlin DER parsing (no BouncyCastle); unit-tested against the real broken leaf + R13 + ISRG Root X1, including end-to-end PKIX validation.
Add a Platform Setup guide explaining the missing-intermediate failure mode, the opt-in HttpsURLConnection.setDefaultSSLSocketFactory install in the host Application, how the additive trust manager works, and its caveats. Link AiaTls.trustManager() for custom HTTP clients.
Address code-review findings on the AIA TLS code: - Fetcher now returns all certificates from a CA-Issuers URL and completeChain selects the genuine issuer among them, instead of taking only the first cert. Fixes a silent failure for PKCS#7 bundles where the needed intermediate isn't first; the contract no longer over-claims. - Restrict fetches to http/https and route https fetches through a plain (non-AIA) socket factory, so an https CA-Issuers URL can't recurse into AIA chasing once the factory is installed as the process default. - Cache only non-empty results so transient fetch failures stay retryable. - Extract a shared CertFixtures test helper (dedupes loadCert); add a multi-certificate selection test. - Doc clarifications: DerReader does no bounds-checking by design; socketFactory() is intended to be built once.
MPRemoteCommandCenter.likeCommand (an MPFeedbackCommand) had a single system surface — a pre-iOS-15 lock-screen menu shown in place of the previous-track button — which Apple silently removed in iOS 15. On the iOS 16+ target nothing can invoke it: CarPlay uses its own CPNowPlayingButton heart, and Siri "I like this" routes through INUpdateMediaAffinityIntent. On Android the like command was never invoked at all — voice favoriting goes through the rating path (onSetRating -> setActiveTrackFavorited / onRemoteSetRating). So the like/dislike/bookmark feedback commands and their whole callback chain (onRemote*/handleRemote*/remote*) were dead on both platforms. Remove them from the Nitro spec, web stub, and iOS/Android impls, and drop the iOS likeCommand registration. The favorite capability itself (setFavoriteMatch row hearts, CarPlay heart) and the onRemoteSetRating rating path are unaffected. Refs #67, #71
Sharpen the intro around the browse-first thesis and the surfaces the library spans (app screens, lock screens, CarPlay, Android Auto, voice, web) with one shared playback/browse model. Refresh the Features list: add car cold-start and a dedicated Favorites bullet, restore the fire-and-forget contrast on the callbacks bullet, fix the <App> placeholder so it renders, and normalize bullet/label styling.
…vorites onSetRating now maps a controller heart rating straight to setActiveTrackFavorited (fires onFavoriteChanged), dropping the onRemoteSetRating bridge emission. Adds TrackFactory advertisement tests, including capability-off (favorited=null) -> no userRating.
The general rating surface (Rating union, Track.rating, RatingType, the Android ratingType option, onRemoteSetRating/handleRemoteSetRating) was unused by consumers and inert natively. Remove it across the Nitro spec, web stub, iOS, and Android, and regenerate bindings. Android voice 'like' now flows solely through onSetRating -> setActiveTrackFavorited (see RatingFavorites). Refs the favorites design doc.
Add a consolidated Favorites guide (enable, hydrate, system + in-app favoriting, a Favorites tab, search-within-favorites, cache refresh via notifyContentChanged) and register it in the sidebar; link it from the README Favorites bullet. Retire "folder" across the docs in favour of the glossary's "browsable" for the shape and concrete nouns (tab, list, route) for instances; record the guidance under Browsable in CONTEXT.md. Surface CONTEXT.md and docs/adr/ from the root and website CLAUDE.md so the glossary is discoverable.
It referenced a non-existent `set-rating` capability — leftover rating vocabulary. The heart is gated by the `favorite` capability.
…etFavorites Add a Public prose note under Browsable (use the shape term, name concrete instances, worked examples) and favorites-collection ownership under Favorited; fix two latent self-contradictions (Track was 'the node type' while avoiding Node; 'Playable Tracks are leaves' while avoiding Leaf). Rewrite the favorites hydration section to lead with the decoupling benefit and mark setFavorites recommended; broaden 'Surfacing favorites' to tab / nested entry / group. Align terms across the docs: active track, collection, /favorites content, and replace lowercase 'node' with Track/tab/entry.
- Fetch http CA-issuer certs via a raw Socket so the request isn't blocked by NetworkSecurityPolicy on apps targeting API 28+; https stays on HttpsURLConnection with the plain socket factory - Parse raw HTTP/1.0 responses (status, Location, chunked bodies) and follow up to 5 CA-issuer redirects - Add tests for the response parser - Document the fix in the Android certificates guide
- Add the Artwork guide plus feature guides (playback, queue, errors, metadata, equalizer, sleep-timer, hooks, network, battery, audio-output, automotive, browser, track, configuration, migrating-from-track-player, native-callback-fetch)
- Polish the Search guide and the CarPlay / Android Auto / now-playing / gate / getting-started / basic-usage guides
- Alphabetize the Player / Browser / Extras sidebar and place Artwork under Player
- Replace unresolvable {@link} references with backticks and tidy JSDoc examples in src; update the example app and README
- Update typedoc config and regenerate the API index
- Add the iOS update-options consolidation plan
- android: move the architecture overview (diagram, components, data flows, threading, error handling, testing) out of CLAUDE.md into a new ARCHITECTURE.md; CLAUDE.md now points to it and keeps the code-style notes - ios: refresh ARCHITECTURE.md and CLAUDE.md for the TrackPlayer / PlaybackCoordinator split and updated data flow - point the /android-diagram command at android/ARCHITECTURE.md
Replace the iOS-only `IosOutput`/`getIosOutput`/`openIosOutputPicker` surface with a cross-platform `Output` / `getOutput` / `onOutputChanged` / `openOutputPicker` / `supportsOutputSwitcher` API. `OutputType` collapses iOS's granular Bluetooth ports into a shared set. Android implementation: - OutputMonitor: reports the actively-routed media device via AudioManager.getAudioDevicesForAttributes (API 33+, reflects manual reroutes the system output switcher makes while a device stays connected); coarse fallback over connected devices on API 24–32. Change detection is event-driven (AudioDeviceCallback for connect/disconnect) plus a foreground-gated poll (ProcessLifecycleOwner) for manual reroutes, which emit no public callback; an immediate read on foreground entry. - OutputSwitcher: opens the system Output Switcher (MediaRouter2 on API 34+, MEDIA_OUTPUT settings panel on 30–33; no-op + warn below 30, never throws). getOutput() works on all Android versions and returns Output?; only the switcher is gated to API 30+ (gate UI on supportsOutputSwitcher()). Remove stale website/GUIDE_TODO.md; update output/hooks guide docs.
…ttles STATE_READY now maps to PlaybackState.READY only when playWhenReady=false. While playing it's a transient before PLAYING, so suppressing it avoids a settled/non-loading flicker mid-startup (none→loading→buffering→playing). While paused it IS the settled signal consumers need: a stream that finished buffering while paused otherwise never tells the UI to clear its loading spinner (radiogarden/mono#3325). ExoPlaybackStateChanged carries playWhenReady; PlayerListener passes exoPlayer.playWhenReady. Updated PlaybackStateMachineTest accordingly.
- Add a custom VitePress theme: Radio Garden green palette mapped onto the brand variables, solid green hero title, README-derived hero copy and feature cards (each linked to its guide) - Add BrowseDemo, a self-navigating browse list rendered in the hero — a shuffle-bag of stations, iOS-style push transitions, and a now-playing screen with Live / timeline state and emoji artwork, under a soft one-point-perspective 3D tilt - Update the site description to the README's positioning line
…droid) Selecting a track from the browse tree routes through navigateTrack, where playable children carry a contextual URL and expand into a queue. The web `expandQueueAndPlay` only called `setQueue()`, which — since setQueue keeps the current play/pause state — left the first track of a freshly expanded queue paused; subsequent selections played because they hit the skip-to-existing path which already calls play(). The single-track load fallbacks had the same gap. Both native platforms already play on select: Android's expand path does `setQueue(...)` + `player.play()`, and iOS's executePlayback passes `playWhenReady: true` to every intent (skipTo / setQueue / loadTrack). Make the web match — `expandQueueAndPlay` and the two `load()` fallbacks now also call `play()`, so selecting a track is consistently an intent to play.
The web player only uses shaka.Player, shaka.Player.isBrowserSupported and shaka.polyfill.installAll — all in the core. It imported shaka-player.ui, which bundles Shaka's prebuilt player-controls UI (shaka.ui.*) that we never use (the library has its own now-playing surface / the consumer renders their own UI). Switch to shaka-player.compiled: identical streaming engine (HLS/DASH/DRM/ABR), ~52 kB gzip smaller on web. No API changes.
The web implementation never integrated with navigator.mediaSession, so a playing track never reached the lockscreen / OS media notification / hardware media keys. Add that integration, mirroring the native iOS/Android behaviour. - MediaSessionManager: player-agnostic DOM adapter over navigator.mediaSession (metadata, playbackState, positionState, action handlers). No-op when the API is unavailable; clears position for live streams (infinite/0 duration). - RemoteCommandController: player-aware bridge that owns a MediaSessionManager and routes incoming commands via the native handle-or-default-then-emit contract (prefer handleRemoteX, else default transport, always emit onRemoteX). Reads the player through a Pick<AudioBrowserSpec, ...> capability interface so the contract can't drift; handlers are read live so consumers can assign them after construction. - NativeAudioBrowser wires the controller into now-playing, playback-state and progress, replacing the previously dead handleRemoteX slots.
Web mapped <audio>/Shaka events straight to state from ~5 call sites with no transition guards. A terminal error was then clobbered by the `pause` event that onError()'s unload() emits — onStateUpdate only guarded `_isStopped`, not `error` (#80). Port iOS's pure nextPlaybackState(current, event) transition table to TS and funnel the racy element/Shaka observations through it; deliberate commands (stop, error) still set their terminal state directly. Guards now match the native platforms: pause-from-error is suppressed (the fix), load `ready` only settles from `loading`, and a rebuffer that finishes mid-playback no longer flashes `ready`. The transition table is ported verbatim from the native truth tables (ios/Tests/PlaybackStateMachineTests.swift, android PlaybackStateMachineTest.kt) so web stays behaviourally in sync; integration tests lock the web-specific seam (the _isStopped gate, the error-clobber fix, the queue-advance hook). Refs #80
…ction getPlayingStateFromPlayback was an inline, untested copy of logic the comment itself flagged as "Matches Android's PlayingStateFactory.derive()". Extract it to a pure derivePlayingState(playWhenReady, state) mirroring the native derivations (android PlayingStateFactory, ios PlayingStateManager), with the truth table ported verbatim from PlayingStateFactoryTest.kt. Closes a parity/test gap and trims NativeAudioBrowser back under 1k lines.
Replace the two inline setInterval timers (progress updates + the 1s playback interval tick) and their raw handle bookkeeping with a small, tested PlaybackTimer that owns interval mechanics + a gate predicate, mirroring Android's playback-state-gated PlaybackTimer. Behaviour is unchanged (same intervals, same playing/loading/buffering gating, same payloads). Part of #82.
Replace the if-chain in navigateTrackAsync with classifyTrackNavigation, a pure contextual/browse/playable/invalid decision (mirrors iOS TrackSelector), and dispatch on it. Factors the duplicated load-single-track-and-play into playSingleTrack and lifts the BrowserPathHelper contextual-URL parsing out of the entry class into the tested classifier. Behaviour unchanged. Part of #82.
Move the play-queue data and all the pure index/shuffle math (next/previous, wrap-around, shuffle ordering, remove/move index adjustment) out of PlaylistPlayer into a composed, fully-tested QueueManager — the web analog of iOS's QueueManager + ShuffleOrder. PlaylistPlayer keeps only the orchestration (load/play when the index changes, advance on track end); NativeAudioBrowser's direct queue-state accesses now go through `this.queue`. remove() returns a typed RemoveOutcome (no-current/kept/reload/emptied) so the player decides reload-vs-stop without the queue touching the engine. Behaviour unchanged; PlaylistPlayer drops 348 → 177 lines. Part of #82.
Records the decision that the iOS/Android/web players share pure behavioural contracts (state machine, PlayingState derivation, queue navigation, remote command rule) under matching names, but each keeps its platform-idiomatic structure. Native composition reflects service/process boundaries web doesn't have, so web retains inheritance; the deferred inheritance collapse from #82 is intentionally not pursued. Refs #82
CONTEXT reserves "Playlist" for user-curated Browser content and lists it under *Avoid* for the player's track list, which is the Queue (the public API already uses setQueue/getQueue/onPlaybackQueueChanged). Align the lone internal holdouts: PlaylistPlayer → QueuePlayer, onPlaylistEnded → onQueueEnded, and the RepeatMode.Playlist key → RepeatMode.Queue (value already 'queue'). Internal-only; no public API or behaviour change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migrating an existing integration — the API changes to know about:
setupPlayeroptions:autoUpdateMetadata→nowPlaying(boolean | formatter);ios.playbackRatesreplaces flatiosPlaybackRates;shuffleremoved from Android options (usesetShuffleEnabled());Optionsno longer carriesrepeatMode(usegetRepeatMode());keepSessionAliveOnErrornow defaults totrue(passfalsefor the old Android tear-down-on-error);RetryConfig.maxRetriesis now optional. Removed dead exports:autoUpdateMetadata,AndroidSetupPlayerOptions,IOSSetupPlayerOptions,PlayerOptions,FeedbackOptions. Wire types renamed (PartialSetupPlayerOptions→NativeSetupPlayerOptions,Partial{Android,IOS}SetupPlayerOptions→Native{Android,IOS}SetupOptions).artist, the browse-list line fromsubtitle(clarifiedTracksemantics);nowPlayingArtworkreplacesnowPlayingUrlTemplate.buttonTitle/onBrowseGateButtonPressedremoved,shouldGate→GateResolver.RatingTypeand the like/dislike/bookmark feedback-command surface are gone — rating is now internal to Android favoriting (the Assistant "like"/heart routes to favorites, firingonFavoriteChanged).SearchModereshaped from a flat enum into container verticals, plus a newreferenceaxis.IosOutput/getIosOutput/useIosOutput/openIosOutputPickerreplaced by cross-platformOutput/getOutput/useOutput/openOutputPicker, plusonOutputChanged/supportsOutputSwitcher(OutputTypecollapses iOS's granular Bluetooth ports into a shared set).iosupdate bag (playbackRates/carPlayUpNextButton/carPlayNowPlayingButtons), readable and runtime-settable viaupdateOptions({ ios }).setQueueno longer starts playback — it keeps the current play/pause state; callplay()to begin.(Each item is detailed in its section below.)
Player setup
setupPlayer:setupPlayer(options)is now the complete launch description, applied natively in one step — runtime options (capabilities, jump intervals, notification buttons, …) are staged before the engine exists, and the initialplayWhenReady/repeatModeare applied the moment it does. No setup-then-command choreography, and no window where the media session runs with default controls. The TS wrapper routes each field to its wire home so consumers never learn the taxonomy.setPlayWhenReady/setRepeatMode) andgetOptionsare now safe pre-setup on both platforms (staged, strict last-write-wins) instead of throwing (Android) or silently no-oping (iOS). CallingsetupPlayeragain reconfigures in place.onOptionsChanged(useOptionswas inert);getOptionsnow returns the resolvedOptionsshape instead of a partialUpdateOptions.autoUpdateMetadatasetup option replaced bynowPlaying(boolean | formatter); newkeepSessionAliveOnErrordefaults totrue(Android previously tore the session down on terminal errors — passfalsefor the old behavior); wire types renamed (PartialSetupPlayerOptions→NativeSetupPlayerOptions,Partial{Android,IOS}SetupPlayerOptions→Native{Android,IOS}SetupOptions);ios.playbackRatesreplaces flatiosPlaybackRatesin setup;shuffleremoved from Android options (it's player state viasetShuffleEnabled());Optionsno longer carriesrepeatMode(usegetRepeatMode());RetryConfig.maxRetriesnow optional (omit to retry uncapped); removed deadautoUpdateMetadata/AndroidSetupPlayerOptions/IOSSetupPlayerOptions/PlayerOptions/FeedbackOptionsexports.Now Playing & lock screen
nowPlaying) callback — controls how track metadata is published/refreshed on the now-playing surface, on iOS & Android (e.g. render live ICY/timed metadata, transient status lines, or an error message in place of the title). Itsstalledsignal is aStallReason('buffering' | 'offline'), so the line can tell a rebuffer apart from an offline device (e.g. "Reconnecting…" vs "No internet connection") without a separate connectivity read; the surface re-renders on connectivity changes so that line stays current.flashNowPlaying(update, durationMs)/clearNowPlayingFlash()— a short-lived line that outranks the formatter while active (e.g. feedback for a refused command). iOS & Android.keepSessionAliveOnError— keep the media session + Android Auto transport controls alive through a terminal stream error, so users can skip away from a dead station instead of a torn-down session (Android-only effect; iOS already does this)nowPlayingArtwork, replacesnowPlayingUrlTemplate— breaking) so the lock screen / CarPlay art can differ from browse-list thumbnails; supports{id}token substitutionartist, browse-list line fromsubtitle—title/subtitle/artistsemantics onTrackare now clarified in docs. This allows formatting car-play / AA list items differently from how they will look in the now playing screen.CarPlay & Android Auto
Track.albumUrl(or theresolveAlbumUrlconfig callback, resolved on track changes so the line only surfaces when a destination exists) makes the CarPlay Now Playing album line tappable, pushing that browse destination. CarPlay renders the tappable line from thealbummetadata — a previously undocumented platform behavior, now covered in a new docs guide (see Docs)GateResolverdecides per request whether a surface is gated, and anonGateevent fires at each gate-hit site (CarPlay / Siri / Android Auto). The gate fails closed on resolver error, and its state is snapshotted atomically per request. Blocks navigation including the album line and Siri search while gated. (Reshaped from the earlier gate-button API —buttonTitle/onBrowseGateButtonPressedremoved — and thermonuclear-reviewed across iOS/Android/web/TS.)isCarConnected()/onCarConnectedChanged(iOSisCarPlayConnected/onCarPlayConnectedChanged), driven by CarPlay scene connect/disconnect.carPlayLoadingTitleconfig option (app-localized) shown on loading screens below iOS 18.4; the system spinner (18.4+) now also covers the startup screen and lazy-loading tabsINPlayMediaIntent,INUpdateMediaAffinityIntentandINAddMediaIntentare now handled in-app through the app'sapplication(_:handlerForIntent:)— the library vends the handlers viaRNABAudioBrowser.handlerForIntent, so resolution runs in the live JS runtime with current player state and no Intents app extension is needed. Cold Siri launches boot React Native headless and wait on a bounded readiness gate.handlePlayMediaIntentsearches the configured search route, queues and plays — filters non-playable results and drills into a browsable first result.resumePlaybackresumes the last session, restoring the queue from persisted playback state on a cold start instead of starting a new search.genreNames/artistName/albumName/mediaType(song/playlist) map to structured search params (Android parity), with a genre-name fallback when the spoken query is empty.INUpdateMediaAffinityIntent(INMediaAffinityTypelike/dislike) andINAddMediaIntentmap onto the library-managed favorite state of the currently-playing track — the same path as the CarPlay favorite button — firingonFavoriteChanged. A flat favorites collection resolves the add destination to the user's library; named playlists remain a designed consumer-callback extension point.SearchModereshaped from a flat enum into container verticals, plus a newreferenceaxis that models the play-funnel (resume vs. fresh search vs. "play my favorites"). iOS and Android map their respective voice-intent criteria (mediaType/ genre / artist / album) into the sameSearchParams, andreference='my'is serialized consistently across platforms so a consumer's search route can resolve "my favorites" the same way everywhere.formatNavigationError(newempty-content/timeoutcodes, 15s resolve watchdog) — distinct copy for e.g. empty Favorites vs empty search, replacing hardcoded EnglishRequest/Browse resolver system
buildApiRequest/applyLayers; media/artwork use rule-aligned analogs.)buildApiRequest(request → kind → route layering) on Android too — previously a hand-rolled fork per kindpathrule unified (the Request-Config Layer in CONTEXT.md): a layer's staticpathnever overrides the navigated/base path — only a transform may change it. Android let static paths win; iOS search requested a literal/__searchwhenever a search config had a static path. All three platforms now match, pinned by teststransform, its static fields are not merged — the transform's return value replaces the lower-layer config (documented + test-pinned)request(and per-kind configs) can be aRequestConfigResolver— resolved once per content generation, re-resolved oninvalidateAllContent(), may be async — distinct from the per-requesttransform'*'overrides the default; keys starting with__are reservedBrowserManager+URLResolution)query/method/body/contentTypewere dropped, and headers were dropped entirely when nobaseUrlwas configured anywhereTrack.request(narrow by design —userAgent/headers/queryonly, so a server-sourced track can't repoint its host)media.resolvecan now return sync or async (resolveSync/transformSyncsiblings); resolved User-Agent honored for playback;resolveSync-only media configs now receive the track (wasresolve-only)Artwork resolution
resolvecallback (was either/or), and the artwork transform also runs at browse time — JS-sideartworkSourcewas untransformed/unsigned on iOS onlycontent://provider (Browse-list artwork URIs bypass the session BitmapLoader on Android Auto/AAOS — header-bearing or custom-decoded image URLs fail #63): Media3's legacy browser bridge hands the car a bareartworkUriand never consults the sessionBitmapLoader, so browse-list art that needs custom request headers or SVG decoding wouldn't render on the car, and embedding bitmaps to work around it riskedTransactionTooLargeExceptionon large lists. Browse items now carry a short opaquecontent://…/art/<token>URI; an exported, token-gatedContentProviderresolves that token in our own process through the same Coil path the now-playing surface uses (custom headers + SVG decode + size hint) and serves a seekable file FD from a bounded on-disk cache. Non-http(s)artwork (e.g.android.resource://tab/category icons) passes through toartworkUriunchanged. The token gate means the provider can only ever serve artwork the library itself registered — never an arbitrary caller-supplied URL (no SSRF / header-harvest). Device-verified on the Desktop Head Unit.Queue & playback
setQueuethreadsstartIndex/startPositionMsand swaps the queue atomically (iOS preserves theMPNowPlayingSessionlink across the swap); it no longer implicitly starts playback — it keeps the current play/pause state, so callplay()to beginseekToLiveEdge()primitiveAVPlayerbut previously left the state stuck onplaying(thetimeControlStatus → pausedwas swallowed whileplayWhenReadywas still true) — so any state-driven UI never updated. We now observeAVAudioSession.interruptionNotification, force a realpaused, and auto-resume only if we were playing and the system signals.shouldResume— matching the auto-pause/resume Android's ExoPlayer already does via audio-focus handlingios.category/mode/options/policyat setup, and is released on stop so other apps' audio can resume (iOS: release the audio session on stop (deactivate) so other apps' audio can resume #60). Playback also pauses when the active output device disappears (e.g. headphones/AirPlay route drop) instead of continuing silently.AVAudioSession.mediaServicesWereReset(rebuild the player/session), an AirPlay stall watchdog, a spurious-EOF guard, and an interruption filter — so transient route/service hiccups recover instead of wedging playback.Track.idround-trips end-to-end (Track has no opaque identifier — consumers must smuggle identity throughsrc#47) — uninterpreted, threaded through setQueue/getActiveTrack/onActiveTrackChanged and per-track resolve hooks (e.g.{id}substitution in artwork resolution)fadeDuration)trackPlaybackTime— opt-in periodic playback-time tick (onPlaybackInterval) for consumers that track elapsed time per track; a single sharedPlaybackTimerdrives it on iOS/Android (the web stub uses a plain interval)Audio output
getOutput()/useOutput()/onOutputChangedreport the actively-routed output —Outputcarries a sharedOutputType, the devicename, and anexternalflag — andopenOutputPicker()/supportsOutputSwitcher()present the system output switcher. Replaces the iOS-onlygetIosOutput/useIosOutput/openIosOutputPickersurface;OutputTypecollapses iOS's granular Bluetooth ports into a shared set.getOutput()works on every supported Android version — it reports the actively-routed media device viaAudioManager.getAudioDevicesForAttributeson API 33+ (reflecting manual reroutes the system switcher makes while a device stays connected), with a coarse fallback over connected devices on API 24–32. Change detection is event-driven (AudioDeviceCallbackfor connect/disconnect) plus a foreground-gated poll for manual reroutes that emit no public callback, with an immediate read on foreground entry.openOutputPicker()opens the system Output Switcher (MediaRouter2 on API 34+, theMEDIA_OUTPUTsettings panel on 30–33); below API 30 it's a no-op + warn, never throws — gate the UI onsupportsOutputSwitcher()(only the switcher is gated;getOutput()is not).IosOutput/getIosOutput/useIosOutput/openIosOutputPickerremoved in favor ofOutput/getOutput/useOutput/openOutputPicker(+onOutputChanged/supportsOutputSwitcher).Android playback engine (media3 1.10.1)
playback-stallederror instead of looping. Stuck-buffering backstop tuned from media3's 10-minute default to 60s.onPlaybackResumptionadopts media3 1.9'sisForPlaybackoverload, so the device-boot resumption notification builds from local metadata instead of network calls that may fail before connectivity is upErrors & robustness
not-connected-to-internet)playback-stallederror code (Android emits it when stall-recovery is exhausted; iOS gains the matching code for parity)empty-content(empty browse result is now modeled as a navigation error) andtimeout(browse resolve watchdog)reloadResolving→ full media-resolve pipeline; Android re-resolves on the next data-source open after anIOException) and the connectivity-restored reconnect (iOS re-resolves when reconnecting a silently-stalled live stream; Android re-resolves on its network-error reconnect). Non-network stall recovery (stuck-player / AirPlay watchdog) re-prepares with the last-resolved URL, since a transient buffering stall doesn't imply a stale URL.AiaTls) fills in intermediate CA certificates a server omits — the recovery iOS and browsers do automatically but Android's default trust manager doesn't, which otherwise fails playback withTrust anchor for certification path not found. Additive-only: it follows the leaf's AIA "CA Issuers" pointer, fetches the missing intermediate, and re-validates against the system trust anchors, so it can never weaken trust. Pure-Kotlin DER parsing (no BouncyCastle). Not installed automatically — the host app opts in withHttpsURLConnection.setDefaultSSLSocketFactory(AiaTls.socketFactory()).qparam + bareTrackarray; web page-object response shape)Docs
Event+addEventListener→ per-eventon*/use*, no playback service,Capability[]→ acapabilitiesobject, enums → string unions,Track.url→src. The playback/queue API is intentionally familiar, so most code ports with small changes.album, that the lock screen never showsalbum, that one metadata dict feeds all surfaces. None of this is documented by Apple. The CarPlay guide covers loading states, album-line navigation, and full Siri voice-search setup (entitlements, Info.plistINIntentsSupported, in-appapplication(_:handlerForIntent:)wiring — no Intents Extension).fetchstalls when a native callback runs backgrounded — itswhatwg-fetch/setTimeoutresolution pauses withRCTTiming— so usereact-native-nitro-fetch).docs/towebsite/;docs/now holds repo-internal documents (ADRs, plans) that previously would have been published to the site.