React Native audio playback built on libmpv, with a player-agnostic media-session layer: lock screen, notification, Bluetooth and background playback that keep working after the app's UI is gone. Four independent packages with no cross-dependencies — a player, an audio-focus arbiter, a media session that drives lock screens for any player, and a Chromecast sender. The C++ core binds libmpv's client API through Nitro Modules, with no bridge layer.
Status: v0.1.0 — published, early, pre-1.0. The packages are on npm under
@afkcodes/timbre-*and the install line below works. The Android stack is device-verified end to end; iOS playback and the media notification are device-verified; cast and CarPlay on iOS are not yet. The API may still change before 1.0.

The example app: a live station → equaliser
presets (the curve morphs) → live FFT visualiser.
npm install @afkcodes/timbre-player @afkcodes/timbre-audio-session @afkcodes/timbre-media-session react-native-nitro-modules
cd ios && pod install # downloads + verifies the pinned libmpv xcframeworksSetup is one Info.plist key on iOS and nothing on Android. See
Requirements.
A gapless music player with a lock screen, a notification and a session that survives process death.
// src/playback.ts
import { Player } from '@afkcodes/timbre-player'
import { AudioSession, AudioSessionPresets, wireAudioSession } from '@afkcodes/timbre-audio-session'
import {
BaseMediaHandler, MediaService, withPersistence,
type MediaItem, type PersistedMediaService,
} from '@afkcodes/timbre-media-session'
import { storage, type Track } from './library'
let player: Player
let service: PersistedMediaService
let queue: readonly Track[] = []
const toItem = (t: Track): MediaItem => ({
id: t.id, title: t.title, artist: t.artist, album: t.album,
artworkUri: t.artworkUri, duration: t.durationMs,
})
class Handler extends BaseMediaHandler { // every remote surface lands here
override play(): void { player.play() }
override pause(): void { player.pause() }
override async seekTo(ms: number): Promise<void> { await player.seekTo(ms / 1000) }
override async skipToNext(): Promise<void> { await player.playlist.next() }
override async skipToPrevious(): Promise<void> { await player.playlist.previous() }
override onTaskRemoved(): void { service.save() }
}
function broadcast(): void {
const s = player.state
service.setPlaybackState({
status: s.playing ? 'playing' : 'paused',
position: s.positionAnchorMs, // an anchor; every surface projects it locally
controls: ['skipToPrevious', 'pause', 'skipToNext'],
capabilities: ['play', 'pause', 'seek', 'skipToNext', 'skipToPrevious'],
queueIndex: s.playlist.index,
})
}
export async function start(): Promise<void> {
player = await Player.create({ prefetchPlaylist: true }) // one mpv playlist, gapless
wireAudioSession(player, { preset: AudioSessionPresets.music })
service = withPersistence(
await MediaService.init(() => new Handler(), {
android: { notificationChannelId: 'playback', notificationChannelName: 'Playback' },
}),
storage,
)
player.onStateChange(broadcast)
player.on('trackChanged', ({ index }) => {
const track = queue[index]
if (track !== undefined) service.setMediaItem(toItem(track))
})
}
export async function playAlbum(tracks: readonly Track[]): Promise<void> {
queue = tracks
await AudioSession.activate() // before play(), always
service.setQueue(tracks.map(toItem))
await player.loadPlaylist(tracks.map((t) => t.url))
}// index.js — a bare side-effect import, so the module runs at boot
import './src/playback'
import App from './App'Whole apps, not snippets. Each one is code that runs.
| Recipe | What it covers |
|---|---|
| A music player | Queue, gapless, shuffle and repeat, artwork, persistence, an EQ screen |
| A radio app | Live streams, ICY now-playing, reconnection, no scrubber |
| A podcast / audiobook player | Chapters, speed, ±30 s, a native sleep timer, resume position |
| A self-hosted library client | Signed and expiring URLs that stay gapless, per-source auth headers |
| In the car | Android Auto and CarPlay from one browse handler, plus voice |
| Cast to a speaker | Chromecast handoff, receiver-side queue, one set of UI channels |
Four separate installs with no dependencies on each other. Take the rows that describe your app and ignore the rest.
| You want | Install |
|---|---|
| Audio playback, a gapless queue, EQ, chapters, a visualizer | @afkcodes/timbre-player |
| …that keeps playing in the background, on the lock screen and in the notification | + @afkcodes/timbre-media-session |
| …that ducks for navigation prompts, pauses for calls and stops when the headphones come out | + @afkcodes/timbre-audio-session |
| …that can hand off to a Chromecast | + @afkcodes/timbre-cast |
| …that the car can browse — Android Auto tabs, CarPlay templates, voice "play X" | already in media-session: implement getChildren + playFromMediaId |
A lock screen for a player that is not ours — RNTP, expo-audio, a TTS engine |
media-session alone |
| Focus / interruption handling for someone else's player | audio-session alone |
Both session packages take their player through a structural interface, so nothing forces you to adopt the engine to use the session layer, or the reverse.
| track-player | expo-audio | rn-video | queue-player | timbre | |
|---|---|---|---|---|---|
| Engine | ExoPlayer / AVPlayer | ExoPlayer / AVPlayer | ExoPlayer / AVPlayer | not documented | libmpv 0.41.0 + FFmpeg 8.1.2 — our own build |
| One identical engine on both platforms | ❌ two engines | ❌ | ❌ | — | ✅ 103 mpv options a side, 101 identical (the two that differ are the platform's audio device) |
| Formats | platform codecs | platform codecs | platform codecs | not documented | everything FFmpeg decodes — MP3, AAC/M4A, FLAC, OGG/Opus, HLS, ICY/Icecast, TrueHD, legacy-charset tags |
| Multiple players | ❌ singleton | ✅ | ✅ | ❌ singleton | ✅ one mpv core each |
| Session layer works with any player | ❌ | ❌ | ❌ | ❌ | ✅ (@afkcodes/timbre-media-session is player-agnostic) |
| Gapless queue | ✅ | ❌ | ✅ | ✅ 25 ms handover, measured on-device | |
| Signed / expiring URLs stay gapless | ❌ | ❌ | ❌ | ❌ | ✅ resolver runs at prefetch time — our own mpv patch |
| EQ / DSP | ❌ | ❌ | ❌ | ✅ 10-band | ✅ 16 ffmpeg filters, 22 tuned EQ presets |
| Casting (Chromecast / AirPlay) | ❌ (V5, commercial: Chromecast/Android + AirPlay/iOS, platform-split) | ❌ | ❌ app-side | ✅ | ✅ Chromecast, both platforms — session handoff, receiver-side queue, live streams |
| Android Auto / CarPlay | ❌ | ❌ | ✅ | ✅ browse tree + voice on Auto, CarPlay templates | |
| DRM (Widevine/FairPlay) | ❌ | ✅ | not documented | ❌ libmpv cannot (Limitations) | |
| Native binary it adds | ≈none (platform codecs) | ≈none | ≈none | — | 3.63 MB downloaded for arm64-v8a, ≈7.1 MB for the iOS device slice (Requirements) |
The other eight rows — background sessions, headers, pitch, chapters, sleep
timer, visualizer, crossfade, remote volume — the sourcing for every cell, and
where react-native-audio-api fits, are in
docs/comparison.md.
Every export is tabulated in its package README.
| Package | Surface |
|---|---|
@afkcodes/timbre-player |
Lifecycle and loading · playback · queue · state, metadata and chapters · hooks · events · audio processing · escape hatch |
@afkcodes/timbre-media-session |
The service and its three channels · handlers and composition · persistence · the car's browse tree · sleep timer |
@afkcodes/timbre-audio-session |
Configure, activate, deactivate · interruption, noisy and route-change listeners · wireAudioSession |
@afkcodes/timbre-cast |
Discovery and sessions · receiver transport and queue · wireCastHandoff · <CastButton/> and hooks |
| React Native | >= 0.82 (New Architecture); developed against 0.87.0 |
| Peer dependency | react-native-nitro-modules |
| Android | minSdk 24, compileSdk 36. Nothing to configure: the media-session manifest merges the foreground-service permissions, the mediaPlayback service, the Android Auto declaration and the artwork provider. POST_NOTIFICATIONS is not required for media notifications |
| iOS | 15.1+; @afkcodes/timbre-cast raises the floor to 16.0 and needs Xcode 26+ to build. One Info.plist key: UIBackgroundModes → audio. CarPlay adds a scene manifest and the com.apple.developer.carplay-audio entitlement |
| Expo | Prebuild workflow, no manual native edits: "plugins": ["@afkcodes/timbre-media-session"] covers the whole library. Expo Go cannot load it; use a development build. Plugin reference |
| Android binary | 3.63 MB downloaded for arm64-v8a; 7,338,952 bytes of stripped libmpv.so. The other three ABIs are in the same band |
| iOS binary | Mpv.framework's device slice is 1,756,424 bytes; ≈7.1 MB across all ten frameworks |
| Google Play services | Casting only — without it Cast.initialize() resolves a typed 'unavailable', never a crash |
| Binary provenance | Downloaded at build time, pinned by tag and SHA-256; a mismatch is a hard build failure. docs/engine.md |
- No DRM. libmpv has no Widevine or FairPlay, so this cannot power a licensed-catalog streaming app.
- iOS cast and CarPlay are unverified on a device. iOS playback and the
media notification are device-tested;
@afkcodes/timbre-castand CarPlay have not been run on Apple hardware — CI compiles and inspects them, but no session has been observed. - Force-quitting on iOS kills playback, and nothing may restart the app for
audio afterwards — which is why
playbackResumptionis Android-only. - An iOS sleep timer armed over silence may never fire, because iOS suspends a backgrounded process once audio stops.
- No crossfade. Built, listened to, and dropped by owner decision (ARCHITECTURE).
- Embedded cover art decodes but cannot be extracted yet. Pass artwork from
your own library as
MediaItem.artworkUri(ARCHITECTURE). - Casting has real ceilings: receivers decode far less than mpv, no
file://sources, no header auth without your own Web Receiver, no lock-screen controls during an iOS session, and AirPlay-as-handoff is out of scope. AirPlay audio output works today with no code — it is an ordinary iOS system output route. - Persistence has two edges: the 30 s autosave rides a JS timer that Android
freezes once the Activity is gone, so add
service.save()onAppState; and a live stream saves position0. - CarPlay has never run. It compiles on CI and mirrors Android Auto's handler contract; no Simulator or head-unit session has been observed.
- Cold browse from the car needs
android.playbackResumption: true. Without it the car sees the last cached lists until the app is opened.
Naming and the first npm publish are done — the four packages are live at
v0.1.0. Next up, owner-approved: @afkcodes/timbre-downloads (offline playback
as a source resolver), LRC lyrics, then video as an additive plugin package.
Still to prove on hardware: iOS cast and CarPlay. Full analysis:
PLAN.md.
This library: MIT. The bundled libmpv/FFmpeg binaries are LGPL v3 (built
with --enable-version3, never --enable-gpl) and dynamically linked — the
App Store-accepted pattern that satisfies the relink obligation. Ship your app's
licenses screen with the mpv/FFmpeg notices. Both binaries come from our public
forks of media-kit's build scripts,
libmpv-android-audio-build
and libmpv-darwin-build,
both mpv 0.41.0 / FFmpeg 8.1.2 / mbedTLS 3.6.7. The full obligation chain:
docs/engine.md.
CONTRIBUTING.md has the whole loop — setup, per-package
tests, the Gradle tasks CI runs, and what counts as verification here.
apps/example is the reference integration and the on-device
test bed. ARCHITECTURE.md records every decision and its
evidence, PLAN.md the analysis, docs/specs/ the
per-package contracts, docs/engine.md the engine we build.