Ideas for future improvements that aren't urgent but worth considering.
Note: FUTURE items are AI coauthored and can include incorrect or incomplete details
- Support animated artwork for Now Playing
- Apple docs: https://developer.apple.com/documentation/mediaplayer/providing-animated-artwork-for-media-items
- Would allow animated album art on lock screen / CarPlay
- Requires using
MPMediaItemPropertyArtworkwith animated image data
Currently, playback resumption always resolves via the browse callback, which requires JS to be ready and potentially a network request.
Idea: Cache the full queue (or at least the playing track) alongside the URL/position in PlaybackStateStore. Use cached data if fresh (e.g., <24h), otherwise fall back to browse resolution.
Benefits:
- Instant resumption without waiting for JS/network
- Works offline
- Better UX for Bluetooth play button
Trade-offs:
- More storage (need to serialize Track array)
- Potentially stale metadata
- Need to decide TTL / freshness heuristic
Implementation notes:
- Could use JSON serialization for Track array
- Consider caching just the single playing track for faster first-play, then expand queue in background
- Browse callback could refresh cache in background after playback starts
Currently retry backoff uses hardcoded values (1s initial, 1.5x multiplier, 5s cap). Could expose these for apps with specific needs.
Potential API:
type RetryConfig = {
maxRetries?: number // undefined = infinite
initialDelayMs?: number // default: 1000
multiplier?: number // default: 1.5
maxDelayMs?: number // default: 5000
}Use cases:
{ multiplier: 1, maxDelayMs: 2000 }- constant 1s retries (aggressive, for time-sensitive streams){ multiplier: 2, maxDelayMs: 10000 }- slower backoff for battery-sensitive apps{ initialDelayMs: 500 }- faster first retry
Trade-offs:
- More API surface to document/maintain
- Risk of misconfiguration (e.g., multiplier < 1)
- Current defaults work well for most streaming use cases
Once updateNowPlaying() is implemented (see TODO-now-playing-metadata.md), consider adding automatic mode for radio streams.
Idea: When onPlaybackMetadata fires (ICY/ID3 tags from live streams), automatically pipe it to the now playing notification.
Potential API:
AudioPlayer.setAutoNowPlayingFromStreamMetadata(enabled: boolean)
// or as a setup option:
setup({ autoNowPlayingFromStreamMetadata: true })Benefits:
- Zero-effort live stream support
- Title/artist from ICY tags automatically appears in notification
- No manual wiring of
onPlaybackMetadata→updateNowPlaying()
Trade-offs:
- Less control (might want to filter/transform metadata first)
- Current explicit approach gives full flexibility
- Could always be done in userland with:
onPlaybackMetadata.subscribe(({ title, artist }) => { AudioPlayer.updateNowPlaying({ title, artist }) })
Decision: Start with explicit control, add auto-mode later if there's demand.
Google Assistant supports two ways to start playback:
- Search-based (
onPlayFromSearch) - User says "play X", app searches and plays - URI-based (
onPlayFromUri) - Google already knows the content URI, direct playback
Currently we only support search-based. URI-based requires a partnership with Google.
What's involved:
- Apply to Google Media Actions program
- Submit a content catalog feed (JSON-LD) with station URIs:
{ "@type": "RadioStation", "name": "BBC Radio 1", "broadcastDisplayName": "BBC Radio 1", "potentialAction": { "@type": "ListenAction", "target": { "urlTemplate": "radiogarden://station/abc123", "actionPlatform": ["http://schema.org/AndroidPlatform"] } } } - Implement
onPlayFromUrito handle direct URI playback
Benefits:
- Faster playback (no search step)
- More accurate matching (Google knows exact station)
- Works when search API is slow/unavailable
- Better handling of stations with similar names
Implementation:
- Add
onPlayFromUrihandler inMediaSessionCallback - Parse URI scheme (e.g.,
radiogarden://station/{id}) - Look up station by ID and start playback
Trade-offs:
- Requires ongoing catalog maintenance and submission to Google
- Business relationship/approval process with Google
- Current
onPlayFromSearchhandles most voice commands adequately
References:
- https://developer.android.com/guide/topics/media-apps/interacting-with-assistant
- https://developers.google.com/search/docs/appearance/structured-data/media-actions
iOS 26 introduced animated album artwork on the lock screen - tapping the artwork expands it and plays the animation.
References:
To investigate:
- How to provide animated artwork via
MPNowPlayingInfoCenter - What formats are supported (GIF, APNG, video?)
- Android equivalent (if any)
Currently, tintable icons in Android Auto require bundled vector drawables via android.resource:// URIs. This means icons must be included in the app at build time.
Question: Is there any way to load vector drawable data from a remote URL?
Possibilities to explore:
- Data URI with SVG -
data:image/svg+xml;base64,...- probably not supported by Media3's artwork loading - Custom ContentProvider - Serve vector XML via
content://URI - complex but might work - HTTP-served vector XML - Unlikely, Android probably treats it as image download not VectorDrawable
Workaround (current):
- Pass app version to API via query params or user agent
- API returns
android.resource://URIs only for icons bundled in that app version - Older app versions get fallback http:// PNG/JPEG URLs
Why this matters:
- Adding new navigation icons requires app update
- Can't dynamically add category icons from server
- Limits flexibility for server-driven UI
To test:
- Try
data:image/svg+xml,...as artwork URI - Check if Media3 has any extension points for custom URI schemes
Android Auto supports visual indicators on media items to show playback progress - useful for podcasts, audiobooks, or any episodic content.
Available via MediaConstants extras:
DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS:VALUE_COMPLETION_STATUS_NOT_PLAYED- unplayed indicatorVALUE_COMPLETION_STATUS_PARTIALLY_PLAYED- started but not finishedVALUE_COMPLETION_STATUS_FULLY_PLAYED- checkmark/complete indicator
DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE- progress bar (0.0 to 1.0, augments partially played)
Potential API:
type Track = {
// ... existing fields
completionStatus?: 'not-played' | 'partially-played' | 'fully-played'
completionPercentage?: number // 0.0 to 1.0
}Use cases:
- Podcast apps - show which episodes have been listened to
- Audiobook apps - track chapter progress
- Music apps - "recently played" with completion indicators
Trade-offs:
- Not relevant for radio/live streams
- Adds complexity for apps that don't need it
- App is responsible for tracking/persisting completion state
References:
Currently childrenStyle applies the same style to both browsable and playable children. Some containers might benefit from different styles (e.g., grid for album folders, list for individual tracks).
Potential API:
type ChildrenStyle = {
playable?: TrackStyle
browsable?: TrackStyle
}
type Track = {
// ... existing fields
childrenStyle?: ChildrenStyle
}Usage:
// Different styles for browsable vs playable children
childrenStyle: { playable: 'list', browsable: 'grid' }
// Same style for both (more verbose than current)
childrenStyle: { playable: 'grid', browsable: 'grid' }Use cases:
- Library section with album folders (browsable → grid) and individual songs (playable → list)
- "Recently Played" mixing playlists (browsable) and tracks (playable)
Alternative - union type for ergonomics:
childrenStyle?: TrackStyle | {
playable?: TrackStyle
browsable?: TrackStyle
}
// Simple (same for both)
childrenStyle: 'grid'
// Explicit (different styles)
childrenStyle: { playable: 'list', browsable: 'grid' }Trade-offs:
- Object-only form is more verbose for the common case
- Union form is more ergonomic but slightly more complex to handle on native side
- Most containers have homogeneous children anyway
- Current simple
childrenStyle: TrackStylecovers ~90% of use cases
Decision: Keep simple childrenStyle: TrackStyle for now. Add nested object (or union) if real-world use cases emerge.
Tesla vehicles don't support URI-based artwork loading in their media player. They require embedded bitmaps in the metadata.
Problem:
- Most cars/Android Auto load artwork from
METADATA_KEY_ALBUM_ART_URI - Tesla ignores URIs and needs
METADATA_KEY_ALBUM_ART(embedded Bitmap) - Without this, Tesla shows no artwork
Solution (from Pocket Casts):
// URI for most platforms
nowPlayingBuilder.putString(METADATA_KEY_ALBUM_ART_URI, bitmapUri)
// Embedded bitmap for devices that don't support URIs (Tesla!)
// Skip on Wear OS and Automotive to save memory
if (!Util.isWearOs(context) && !Util.isAutomotive(context)) {
loadBitmap(artworkUrl)?.let { bitmap ->
nowPlayingBuilder.putBitmap(METADATA_KEY_ALBUM_ART, bitmap)
}
}Considerations:
- Media3's
DefaultMediaNotificationProvidermay handle this automatically - Only matters for users with Tesla vehicles
- Adds memory overhead (bitmap in metadata)
- Need to detect Tesla or just always include bitmap on phone
Detection approaches:
- Always include bitmap (simple, slightly more memory)
- Check
Build.MANUFACTURERfor "Tesla" (if running on Tesla's Android system) - Wait for user reports before implementing
Trade-offs:
- Memory: Bitmap embedded in metadata
- Complexity: Need to load bitmap synchronously or cache it
- Scope: Only affects Tesla users
References:
- Pocket Casts
MediaSessionManager.kt:409-419
ExoPlayer's disk cache uses the URL as cache key by default. This causes cache misses when URLs change but content is the same (signed URLs, CDN rotation, token refresh).
Problem:
# Monday - signed URL
https://cdn.example.com/episode.mp3?sig=abc&expires=123
# Tuesday - same file, new signature
https://cdn.example.com/episode.mp3?sig=xyz&expires=456
Default behavior: Cache miss → re-downloads entire file.
Solution (from Pocket Casts):
MediaItem.Builder()
.setUri(episodeUri)
.setCustomCacheKey(episode.uuid) // stable identifier
.build()Potential implementation:
// In MediaFactory.createMediaSource()
MediaItem.Builder()
.setUri(finalUrl.toUri())
.setCustomCacheKey(track.src ?: track.url) // src is stable, URL may have tokens
.build()Use cases:
- Apps using signed URLs (AWS S3, CloudFront, GCS)
- CDN rotation or load balancing
- Token-based authentication on streams
- URLs with session/analytics parameters
When not needed:
- Live streams (not cached anyway)
- Static URLs that never change
- Apps without disk caching enabled
Trade-offs:
- Minimal implementation effort
- No API change needed (uses existing
srcfield) - Only matters for apps with disk caching + dynamic URLs
ExoPlayer's default MP3 seeking uses constant bitrate estimation, which is fast but inaccurate for variable bitrate (VBR) files. Enabling index seeking builds a seek table for accurate positioning.
Implementation (from Pocket Casts):
val extractorsFactory = DefaultExtractorsFactory()
.setConstantBitrateSeekingEnabled(true) // We already do this
// Optional: enable index seeking for accurate seeks in VBR MP3s
if (settings.prioritizeSeekAccuracy.value) {
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
}Potential API:
setup({
mp3SeekAccuracy?: 'fast' | 'accurate' // default: 'fast'
})How it works:
fast(default): Estimates seek position based on bitrate - instant but may land ±5-10 seconds off in VBR filesaccurate: Builds an index by scanning the file - uses more memory/CPU but seeks to exact position
Use cases:
- Podcast apps where users scrub to specific timestamps
- Audiobook apps with chapter markers
- Any app where "skip back 30 seconds" must be precise
Trade-offs:
- Memory: Index table size depends on file length
- Startup: Small delay while initial index is built
- Not needed for: CBR files, live streams, music (where ±few seconds doesn't matter)
References:
- Pocket Casts uses this as a user preference (
prioritizeSeekAccuracy) - https://developer.android.com/media/media3/exoplayer/seeking
The app currently uses Coil in two places:
Service.kt- Shared ImageLoader for Media3 artwork and browse-time URL transformation- React Native side -
<Image>components for displaying track artwork
These use separate caches, causing artwork to be downloaded twice.
react-native-nitro-web-image is a Nitro-based image library that uses Coil on Android. However, it creates its own ImageLoader instance in HybridWebImageFactory:
// packages/react-native-nitro-web-image/.../HybridWebImageFactory.kt
private val imageLoader = ImageLoader(context) // Own instance, own cacheOptions to share the cache:
-
Share disk cache directory - Configure both ImageLoaders to use the same disk cache path (
cacheDir.resolve("artwork")). Separate instances but shared files. -
Fork/extend nitro-web-image - Create a custom factory that accepts an external ImageLoader.
-
Expose our ImageLoader to JS - Add a Nitro method that loads images using our existing shared ImageLoader instead of using nitro-web-image.
-
Use Coil's singleton - Both could use
context.imageLoader(Coil's app-level singleton) with matching config.
Simplest approach: Configure matching disk cache directories. Even with separate ImageLoader instances, Coil's disk cache is file-based and can be shared.
Implementation notes:
- Our disk cache:
cacheDir.resolve("artwork")(configured inService.kt) - nitro-web-image uses Coil defaults (2% of disk, different directory)
- Would need to either:
- Configure nitro-web-image's cache directory (if supported)
- Or create our own image loading Nitro method
Better approach: Use nitro-image as a dependency
react-native-nitro-image is designed to be used as a shared type across libraries. We could:
- Add
react-native-nitro-imageas a peer/dev dependency - Add
:react-native-nitro-imagetobuild.gradledependencies - Create our own
HybridImageLoaderSpecimplementation that uses our shared Coil ImageLoader - Return
HybridImageSpecinstances from a Nitro method
// In react-native-audio-browser
class HybridArtworkLoader(
private val imageLoader: ImageLoader, // Our shared instance from Service.kt
private val context: Context
): HybridImageLoaderSpec() {
override fun loadImage(): Promise<HybridImageSpec> {
// Use our shared imageLoader with disk cache
return imageLoader.loadImageAsync(url, options, context)
}
}This way:
- We control the ImageLoader instance (with our disk cache config)
- App can render artwork using
<NitroImage />component - Single shared cache for both Media3 and React Native UI
- No fork needed - we implement the protocol with our loader
- SVG support - our ImageLoader already has
SvgDecoderconfigured (nitro-web-image doesn't includecoil-svg)
Trade-offs:
- Adds dependency on react-native-nitro-image
- More setup (CMake, podspec, build.gradle)
- Requires New Architecture
References:
- https://github.com/mrousavy/react-native-nitro-image
- Using the native Image type in a third-party library
- Coil disk cache docs: https://coil-kt.github.io/coil/image_loaders/#disk-cache
Currently artwork accepts only a string URL. We could allow passing ImageSource directly:
interface Track {
artwork?: string | ImageSource // Accept both
readonly artworkSource?: ImageSource // Always output normalized
}Use case: Users who need custom headers/auth for artwork but don't want to configure global artwork transform.
// Instead of configuring artwork.resolve globally:
const track = {
title: 'My Track',
artwork: {
uri: 'https://cdn.example.com/image.jpg',
headers: { Authorization: `Bearer ${token}` }
}
}Implementation notes:
- Native side would detect if
artworkis already anImageSource - If so, use it directly (or optionally merge with global config)
- If string, transform via existing artwork config →
artworkSource - Adds variant type complexity in Nitro (runtime type checking)
Trade-offs:
- Cleaner for one-off auth needs
- But adds union type overhead (variant in C++/Kotlin/Swift)
- API becomes "input can be X or Y, output is always Y"
- May not be worth it if global artwork config covers most cases
Currently ButtonCapability only supports predefined actions: skip-to-previous, skip-to-next, jump-backward, jump-forward, favorite. Users may want custom actions (e.g., "sleep timer", "playback speed", "share").
Potential API:
notificationButtons: {
back: 'skip-to-previous',
forward: 'skip-to-next',
overflow: [
'favorite',
{ id: 'sleep-timer', icon: 'timer', label: 'Sleep Timer' }
]
}Implementation needs:
- Icon resource registration
- Custom session command handling
- JS callback for button tap
iOS uses system-localized text via HTTPURLResponse.localizedString(forStatusCode:), but Android has no equivalent. Currently uses English strings like "Not Found", "Service Unavailable".
Workaround: Users can provide their own translations via formatNavigationError callback.
Potential solution: Bundle localized strings for common HTTP status codes (401, 403, 404, 500, 502, 503, etc.) using Android's resources system.
Make NowPlayingButtons a .nitro.ts type to allow developers to create custom transport buttons beyond the standard set.
Current state: NowPlayingButtons is a simple boolean struct for standard transport controls (skipNext, skipPrevious, jumpBack, jumpForward).
Potential API:
// Instead of just booleans
export type NowPlayingButton =
| 'skipNext'
| 'skipPrevious'
| 'jumpBack'
| 'jumpForward'
| CustomButton
export interface CustomButton {
id: string
title: string
icon?: string // platform-specific icon identifier
onPress: () => void
}
export type NowPlayingButtons = NowPlayingButton[]Benefits:
- Developers could add custom transport buttons (sleep timer, playback speed, share)
- Better extensibility for specialized use cases
- Type-safe integration with native code
Implementation challenges:
- iOS
MPRemoteCommandCenterhas a fixed set of standard commands - custom buttons would need different approach - Android MediaSession custom actions are complex to implement properly
- Would need comprehensive custom button handling system across platforms
- Platform differences make truly custom buttons difficult
Alternative approach:
- Keep current boolean-based approach for standard transport controls (covers 95% of use cases)
- Add separate custom button system if real demand emerges
- Consider this for v2 if standard controls prove insufficient
Allow users to create custom buttons beyond the predefined set (shuffle, repeat, favorite, playbackRate).
Potential API:
carPlayNowPlayingButtons: [
'shuffle',
'repeat',
{ id: 'sleep-timer', icon: 'moon.zzz', handler: () => toggleSleepTimer() }
]Implementation needs:
- SF Symbol name for icon
- Handler callback to JS
- Button state management (selected/unselected)
Tap artist on Now Playing screen to search/browse related content.
- Enable
CPNowPlayingTemplate.shared.isAlbumArtistButtonEnabled - Implement
nowPlayingTemplateAlbumArtistButtonTapped(stub exists inNowPlayingObserver) - Apple's sample: searches for artist name and auto-plays results
- Our approach: search for artist and show results list (more user control)
- Requires search to be configured in browser config
Support CPListImageRowItem / CPListImageRowItemCardElement for list items with multiple images.
Use cases:
- Album row showing multiple cover arts
- Playlist preview with track thumbnails
- "Recently played" with visual history
Support CPGridButton / CPGridTemplate for grid-based navigation with image buttons.
Use cases:
- Genre/mood selection screens
- Quick access shortcuts
- Visual category browsing
Allow patterns to invalidate multiple paths at once.
Potential API:
// Invalidate all favorites sub-paths
notifyContentChanged('/favorites/*')
// Invalidate everything
notifyContentChanged('*')
// Current behavior (single path)
notifyContentChanged('/favorites')Implementation: Pattern matching in native code before refreshing templates.
Spacebar pause/resume for external keyboards must be handled at the Activity level in consuming apps (services don't receive key events).
To do:
- Add reference implementation to example app
- Document in library README
Reference: https://developer.android.com/media/media3/session/control-playback#keyboard
Media3 demo sets both isPlayable = true and isBrowsable = true on album folders. This allows users to tap to play entire folder OR navigate into it.
Needs research:
- How does Android Auto present these? Is there a play icon vs browse tap?
- Does Google Assistant handle "play [album name]" differently for playable folders?
- Are there UX issues reported with this pattern?
Reference: Media3 session demo MediaItemTree.kt:222-238
Reference: https://developer.android.com/media/media3/session/control-playback
Implement ACTION_PREPARE_FROM_SEARCH to reduce playback latency. Google Assistant can call prepare before play to cache content during voice announcement.
Implementation:
- Add handler in
MediaSessionCallbackforonPrepareFromSearch() - Should prepare media without starting playback
Implement ACTION_PLAY_FROM_URI / ACTION_PREPARE_FROM_URI. Only needed if providing URIs to Google via Media Actions.
Reference: https://developer.android.com/guide/topics/media-apps/interacting-with-assistant
Handle EXTRA_START_PLAYBACK intent extra. When Assistant launches app via deep link (not MediaBrowser), it adds EXTRA_START_PLAYBACK=true. App should auto-start playback when receiving intent with this extra.
Implementation: Check for extra in Activity's onCreate() / onNewIntent()
Surface explicit error codes for business logic failures. Currently using Media3's technical error codes (io, timeout, etc.). For better Assistant UX, set specific PlaybackState error codes:
ERROR_CODE_AUTHENTICATION_EXPIRED- User needs to sign inERROR_CODE_NOT_SUPPORTED- Action not supportedERROR_CODE_PREMIUM_ACCOUNT_REQUIRED- Premium feature requested by free userERROR_CODE_NOT_AVAILABLE_IN_REGION- Content geo-restricted
Implementation: Add setPlaybackError(code, message) API for JS to report these conditions
Reference: https://developer.android.com/reference/androidx/media3/common/PlaybackException