Skip to content

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
mainfrom
feature-fry
Draft

Native player overhaul: atomic setupPlayer, now-playing/CarPlay & Android Auto, media3 1.10, request-resolver unification#51
puckey wants to merge 300 commits into
mainfrom
feature-fry

Conversation

@puckey

@puckey puckey commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

⚠️ Breaking changes

Migrating an existing integration — the API changes to know about:

  • setupPlayer options: autoUpdateMetadatanowPlaying (boolean | formatter); ios.playbackRates replaces flat iosPlaybackRates; shuffle removed from Android options (use setShuffleEnabled()); Options no longer carries repeatMode (use getRepeatMode()); keepSessionAliveOnError now defaults to true (pass false for the old Android tear-down-on-error); RetryConfig.maxRetries is now optional. Removed dead exports: autoUpdateMetadata, AndroidSetupPlayerOptions, IOSSetupPlayerOptions, PlayerOptions, FeedbackOptions. Wire types renamed (PartialSetupPlayerOptionsNativeSetupPlayerOptions, Partial{Android,IOS}SetupPlayerOptionsNative{Android,IOS}SetupOptions).
  • Now Playing: renders from artist, the browse-list line from subtitle (clarified Track semantics); nowPlayingArtwork replaces nowPlayingUrlTemplate.
  • Gate: reshaped from the gate-button API to a resolver model — buttonTitle / onBrowseGateButtonPressed removed, shouldGateGateResolver.
  • Public rating API removed: RatingType and the like/dislike/bookmark feedback-command surface are gone — rating is now internal to Android favoriting (the Assistant "like"/heart routes to favorites, firing onFavoriteChanged).
  • Search: SearchMode reshaped from a flat enum into container verticals, plus a new reference axis.
  • Audio output: iOS-only IosOutput / getIosOutput / useIosOutput / openIosOutputPicker replaced by cross-platform Output / getOutput / useOutput / openOutputPicker, plus onOutputChanged / supportsOutputSwitcher (OutputType collapses iOS's granular Bluetooth ports into a shared set).
  • iOS player-UI options consolidated under the ios update bag (playbackRates / carPlayUpNextButton / carPlayNowPlayingButtons), readable and runtime-settable via updateOptions({ ios }).
  • setQueue no longer starts playback — it keeps the current play/pause state; call play() to begin.

(Each item is detailed in its section below.)

Player setup

  • Atomic single-call 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 initial playWhenReady / repeatMode are 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.
  • State setters (setPlayWhenReady / setRepeatMode) and getOptions are now safe pre-setup on both platforms (staged, strict last-write-wins) instead of throwing (Android) or silently no-oping (iOS). Calling setupPlayer again reconfigures in place.
  • Fixes both platforms never emitting onOptionsChanged (useOptions was inert); getOptions now returns the resolved Options shape instead of a partial UpdateOptions.
  • ⚠️ Breaking: autoUpdateMetadata setup option replaced by nowPlaying (boolean | formatter); new keepSessionAliveOnError defaults to true (Android previously tore the session down on terminal errors — pass false for the old behavior); wire types renamed (PartialSetupPlayerOptionsNativeSetupPlayerOptions, Partial{Android,IOS}SetupPlayerOptionsNative{Android,IOS}SetupOptions); ios.playbackRates replaces flat iosPlaybackRates in setup; shuffle removed from Android options (it's player state via setShuffleEnabled()); Options no longer carries repeatMode (use getRepeatMode()); RetryConfig.maxRetries now optional (omit to retry uncapped); removed dead autoUpdateMetadata / AndroidSetupPlayerOptions / IOSSetupPlayerOptions / PlayerOptions / FeedbackOptions exports.

Now Playing & lock screen

  • New now-playing formatter (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). Its stalled signal is a StallReason ('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.
  • Transient now-playing line: 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)
  • Configurable now-playing artwork separately (nowPlayingArtwork, replaces nowPlayingUrlTemplate — breaking) so the lock screen / CarPlay art can differ from browse-list thumbnails; supports {id} token substitution
  • Now-playing metadata publishes the moment a track is selected — before the media URL resolves — by bridging through the session's info center while no player item exists yet, so lock screen / CarPlay never show a blank screen during load
  • ⚠️ Breaking: now-playing renders from artist, browse-list line from subtitletitle/subtitle/artist semantics on Track are 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

  • Album line navigation: Track.albumUrl (or the resolveAlbumUrl config 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 the album metadata — a previously undocumented platform behavior, now covered in a new docs guide (see Docs)
  • Browse Gate (button-less resolver model): block browsing/search on external surfaces (CarPlay full-page gate template, Android Auto tile) while playback continues — e.g. a sign-in or age wall. A GateResolver decides per request whether a surface is gated, and an onGate event 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 / onBrowseGateButtonPressed removed — and thermonuclear-reviewed across iOS/Android/web/TS.)
  • Car-connection signal: isCarConnected() / onCarConnectedChanged (iOS isCarPlayConnected / onCarPlayConnectedChanged), driven by CarPlay scene connect/disconnect.
  • Instant Now Playing: tapping a playable item pushes Now Playing immediately with the tapped track's metadata stamped, instead of animating in an empty screen and filling it after queue expansion + media resolve
  • Loading states: carPlayLoadingTitle config 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 tabs
  • Siri media intents (in-app, no extension): INPlayMediaIntent, INUpdateMediaAffinityIntent and INAddMediaIntent are now handled in-app through the app's application(_:handlerForIntent:) — the library vends the handlers via RNABAudioBrowser.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.
    • Play / search: handlePlayMediaIntent searches the configured search route, queues and plays — filters non-playable results and drills into a browsable first result.
    • Resume: a no-criteria intent (“play «app»”) or resumePlayback resumes the last session, restoring the queue from persisted playback state on a cold start instead of starting a new search.
    • Structured search params: genreNames / artistName / albumName / mediaType (song/playlist) map to structured search params (Android parity), with a genre-name fallback when the spoken query is empty.
    • Like / dislike & add-to-collection: INUpdateMediaAffinityIntent (INMediaAffinityType like/dislike) and INAddMediaIntent map onto the library-managed favorite state of the currently-playing track — the same path as the CarPlay favorite button — firing onFavoriteChanged. A flat favorites collection resolves the add destination to the user's library; named playlists remain a designed consumer-callback extension point.
  • Search verticals + reference axis: SearchMode reshaped from a flat enum into container verticals, plus a new reference axis 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 same SearchParams, and reference='my' is serialized consistently across platforms so a consumer's search route can resolve "my favorites" the same way everywhere.
  • Navigation errors render in-place: browse errors / empty results / timeouts now show a centered empty-state on the destination template, formatted via formatNavigationError (new empty-content / timeout codes, 15s resolve watchdog) — distinct copy for e.g. empty Favorites vs empty search, replacing hardcoded English
  • Robustness batch from a full CarPlay audit: tab-bar updates no longer tear down the user's browse stack (deferred until back at root); the controller survives JS runtime reloads (re-subscribes against the new native instance); a failed template push no longer permanently blocks navigation to that destination; Up Next reuses the (localizable) button title and clamps to CarPlay's item limit
  • Android Auto now surfaces browse errors (visible error tile instead of a bare failure result)

Request/Browse resolver system

  • Every request kind (browse, search, media, artwork) resolves through the same layered contract — request → kind → route, transform-wins-else-static-merge, path carried from base — on iOS/Android/web. (Not one literal function: browse/search use buildApiRequest/applyLayers; media/artwork use rule-aligned analogs.)
  • One request builder per platform: browse and search share a single buildApiRequest (request → kind → route layering) on Android too — previously a hand-rolled fork per kind
  • Layer path rule unified (the Request-Config Layer in CONTEXT.md): a layer's static path never overrides the navigated/base path — only a transform may change it. Android let static paths win; iOS search requested a literal /__search whenever a search config had a static path. All three platforms now match, pinned by tests
  • Transform-wins merge: when a layer provides a transform, its static fields are not merged — the transform's return value replaces the lower-layer config (documented + test-pinned)
  • Resolver thunks: browser config request (and per-kind configs) can be a RequestConfigResolver — resolved once per content generation, re-resolved on invalidateAllContent(), may be async — distinct from the per-request transform
  • Default browse fallback: a path matching no route is fetched over HTTP via the request→browse layers; '*' overrides the default; keys starting with __ are reserved
  • Media/artwork URL resolution lives in the browser layer on Android too (analog of iOS BrowserManager+URLResolution)
  • iOS media config fixes: static query/method/body/contentType were dropped, and headers were dropped entirely when no baseUrl was configured anywhere
  • Per-track request override via Track.request (narrow by design — userAgent/headers/query only, so a server-sourced track can't repoint its host)
  • media.resolve can now return sync or async (resolveSync / transformSync siblings); resolved User-Agent honored for playback; resolveSync-only media configs now receive the track (was resolve-only)

Artwork resolution

  • Display-time artwork is Track-first on Android: the bitmap loader no longer re-transforms whatever (possibly already-transformed) URI the platform hands back — a bounded registry maps produced URIs back to (Track, artwork-config kind) and re-resolves with the real size hint, matching how CarPlay already loads art. The registry is cleared on config changes so display-time never resolves through stale callbacks; unattributable URIs (eviction, process-death restore) are fetched as-is with the static request/artwork headers
  • Artwork transformation no longer depends on player wiring order (Android) — content browsed before player setup gets transformed artwork
  • iOS: artwork static fields now apply alongside a resolve callback (was either/or), and the artwork transform also runs at browse time — JS-side artworkSource was untransformed/unsigned on iOS only
  • An artwork resolver that returns nothing only means “no artwork” when the track has no artwork URL to fall back to (unified web/iOS/Android rule)
  • Android Auto / AAOS browse-list artwork via a content:// 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 bare artworkUri and never consults the session BitmapLoader, 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 risked TransactionTooLargeException on large lists. Browse items now carry a short opaque content://…/art/<token> URI; an exported, token-gated ContentProvider resolves 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 to artworkUri unchanged. 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

  • setQueue threads startIndex / startPositionMs and swaps the queue atomically (iOS preserves the MPNowPlayingSession link across the swap); it no longer implicitly starts playback — it keeps the current play/pause state, so call play() to begin
  • New seekToLiveEdge() primitive
  • iOS audio-session interruptions now reflect in playback state: when another app (Music/Spotify) or a phone call takes over the session, iOS pauses our AVPlayer but previously left the state stuck on playing (the timeControlStatus → paused was swallowed while playWhenReady was still true) — so any state-driven UI never updated. We now observe AVAudioSession.interruptionNotification, force a real paused, 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 handling
  • iOS: reconnect a stalled live stream when connectivity returns — AVPlayer surfaces a mid-stream network drop on a live stream as a buffering stall with no error, so the error-driven retry never engaged and the stream sat reconnecting indefinitely after the network came back. We now reload on the offline→online transition while stalled — parity with Android's stall recovery
  • iOS audio-session lifecycle: the session is now activated on play rather than at setup (so the app doesn't grab audio focus before it plays anything), honors the configured ios.category/mode/options/policy at 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.
  • iOS resilience batch: recover from 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.
  • Opaque Track.id round-trips end-to-end (Track has no opaque identifier — consumers must smuggle identity through src #47) — uninterpreted, threaded through setQueue/getActiveTrack/onActiveTrackChanged and per-track resolve hooks (e.g. {id} substitution in artwork resolution)
  • Sleep timer fades out the volume before pausing (configurable fadeDuration)
  • trackPlaybackTime — opt-in periodic playback-time tick (onPlaybackInterval) for consumers that track elapsed time per track; a single shared PlaybackTimer drives it on iOS/Android (the web stub uses a plain interval)

Audio output

  • Cross-platform output API: getOutput() / useOutput() / onOutputChanged report the actively-routed output — Output carries a shared OutputType, the device name, and an external flag — and openOutputPicker() / supportsOutputSwitcher() present the system output switcher. Replaces the iOS-only getIosOutput / useIosOutput / openIosOutputPicker surface; OutputType collapses iOS's granular Bluetooth ports into a shared set.
  • Android implementation: getOutput() works on every supported Android version — it reports the actively-routed media device via AudioManager.getAudioDevicesForAttributes on 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 (AudioDeviceCallback for 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+, the MEDIA_OUTPUT settings panel on 30–33); below API 30 it's a no-op + warn, never throws — gate the UI on supportsOutputSwitcher() (only the switcher is gated; getOutput() is not).
  • ⚠️ Breaking: IosOutput / getIosOutput / useIosOutput / openIosOutputPicker removed in favor of Output / getOutput / useOutput / openOutputPicker (+ onOutputChanged / supportsOutputSwitcher).

Android playback engine (media3 1.10.1)

  • Bumped media3 1.8.0 → 1.10.1
  • Recover from media3 1.9's new stuck-player detection instead of erroring out: a transient stall reconnects (live tracks rejoin the live edge, on-demand re-prepares in place) rather than tearing down playback. Recovery is bounded to a few attempts and only refills its budget once the stream sustains real playback, so a genuinely dead stream still surfaces one clean terminal playback-stalled error instead of looping. Stuck-buffering backstop tuned from media3's 10-minute default to 60s.
  • onPlaybackResumption adopts media3 1.9's isForPlayback overload, so the device-boot resumption notification builds from local metadata instead of network calls that may fail before connectivity is up

Errors & robustness

  • Normalized offline playback-error code across platforms (not-connected-to-internet)
  • New cross-platform playback-stalled error code (Android emits it when stall-recovery is exhausted; iOS gains the matching code for parity)
  • New navigation error codes: empty-content (empty browse result is now modeled as a navigation error) and timeout (browse resolve watchdog)
  • Android: forwards ICY headers, hardened stream recovery
  • Re-resolve the media URL on retry — on both iOS and Android — so a short-lived signed/token URL refreshes instead of replaying a stale (expired) one. Covers the load-error retry (iOS reloadResolving → full media-resolve pipeline; Android re-resolves on the next data-source open after an IOException) 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.
  • Android: opt-in AIA-chasing TLS (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 with Trust 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 with HttpsURLConnection.setDefaultSSLSocketFactory(AiaTls.socketFactory()).
  • Search fixes (HTTP q param + bare Track array; web page-object response shape)

Docs

  • Full guide site at audiobrowser.dev — a guide per feature area plus a generated API reference (TypeDoc), organized Introduction / Player / Browse / Automotive / Extras / Troubleshooting. Guides written across the overhaul: Getting Started, Basic Usage, Configuration, Track, Hooks; Playback, Queue, Now Playing, Metadata, Remote Controls, Errors; Browser, Artwork, Favorites, Search, Gate; Automotive overview, CarPlay, Android Auto; Sleep Timer, Equalizer, Network, Battery, Audio Output.
  • Migrating from react-native-track-player — a dedicated guide mapping RNTP v4 → audio-browser: async getters → sync getters/hooks, Event + addEventListener → per-event on*/use*, no playback service, Capability[] → a capabilities object, enums → string unions, Track.urlsrc. The playback/queue API is intentionally familiar, so most code ports with small changes.
  • README rewritten and cross-linked to every guide and the API reference, with a track-player migration callout up top.
  • The Now Playing guide documents the field-by-surface rendering matrix (iOS lock screen, CarPlay, Android notification, Android Auto, Bluetooth) and its gotchas — which field feeds which line, that CarPlay's tappable line renders from album, that the lock screen never shows album, 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.plist INIntentsSupported, in-app application(_:handlerForIntent:) wiring — no Intents Extension).
  • Troubleshooting guides: "Android SSL / trust anchors" (the AIA-chasing TLS opt-in) and "Networking in native callbacks" (RN fetch stalls when a native callback runs backgrounded — its whatwg-fetch/setTimeout resolution pauses with RCTTiming — so use react-native-nitro-fetch).
  • CONTEXT.md domain glossary expanded with the library's vocabulary (Request-Config Layer, Browse Gate, …).
  • the docs website moved from docs/ to website/; docs/ now holds repo-internal documents (ADRs, plans) that previously would have been published to the site.

puckey added 24 commits June 5, 2026 16:47
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).
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
@puckey puckey closed this Jun 9, 2026
@puckey puckey deleted the feature-fry branch June 9, 2026 09:44
@puckey puckey restored the feature-fry branch June 9, 2026 09:44
puckey added 3 commits June 9, 2026 12:00
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.
puckey added 30 commits June 23, 2026 17:26
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant