{"_id":"@afkcodes/timbre-player","_rev":"2-2c655e9fa5463cc8d06954b305863218","name":"@afkcodes/timbre-player","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@afkcodes/timbre-player","version":"0.1.0","keywords":["react-native","nitro-modules","libmpv","audio","player"],"author":{"name":"Ashish Kumar"},"license":"MIT","_id":"@afkcodes/timbre-player@0.1.0","maintainers":[{"name":"afkashish","email":"ashish.kmr@outlook.com"}],"homepage":"https://github.com/afkcodes/timbre#readme","bugs":{"url":"https://github.com/afkcodes/timbre/issues"},"dist":{"shasum":"098738c994bb049080f3e44082af87eac174d502","tarball":"https://registry.npmjs.org/@afkcodes/timbre-player/-/timbre-player-0.1.0.tgz","fileCount":266,"integrity":"sha512-VKI7EwNj6Jj1M5o+/kxGKnbo9sWyhE8UcaqTIwCCPkWmXOlPMDWWeM+RC2PaZaInpg74Blmg7W3C/F9hdmKdWQ==","signatures":[{"sig":"MEUCIQDD1pn+VzF4UWm6i9VK89HeHNEgUVJ4LIYI+p9EleXy2AIgNLwI5oRSolUvxUJX8V1gUPDusVhlvBdFJBAk90/V7x0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":2749161},"main":"./lib/commonjs/index.js","types":"./lib/typescript/src/index.d.ts","module":"./lib/module/index.js","source":"src/index","gitHead":"5563c3876bb03f73dd5f1e41b1e945a903a7708a","scripts":{"pod":"npm --prefix ../../apps/example run pod","test":"vitest run","build":"npm run typecheck && bob build","clean":"git clean -dfX","codegen":"nitrogen --logLevel=\"debug\" && npm run build","release":"semantic-release","test:cpp":"cmake -S cpp/tests -B cpp/tests/build && cmake --build cpp/tests/build && ctest --test-dir cpp/tests/build --output-on-failure","typecheck":"tsc --noEmit && tsc --noEmit -p tsconfig.test.json","test:watch":"vitest"},"_npmUser":{"name":"afkashish","email":"ashish.kmr@outlook.com"},"prettier":{"semi":false,"useTabs":false,"tabWidth":2,"quoteProps":"consistent","singleQuote":true,"trailingComma":"es5"},"repository":{"url":"git+https://github.com/afkcodes/timbre.git","type":"git"},"_npmVersion":"11.6.2","description":"React Native audio player built on libmpv, powered by Nitro Modules","directories":{},"_nodeVersion":"24.12.0","eslintConfig":{"root":true,"rules":{"prettier/prettier":["warn",{"useTabs":false,"tabWidth":2,"quoteProps":"consistent","singleQuote":true,"trailingComma":"es5"}],"@typescript-eslint/no-unused-vars":["error",{"argsIgnorePattern":"^_","varsIgnorePattern":"^_","ignoreRestSiblings":true}]},"extends":["@react-native","prettier"],"plugins":["prettier"]},"eslintIgnore":["node_modules/","lib/","android/build/","plugin/build/","nitrogen/","cpp/tests/build/"],"react-native":"src/index","publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"devDependencies":{"jsdom":"^30.0.1","react":"19.2.8","vitest":"^4.1.10","nitrogen":"^0.37.0","react-dom":"19.2.8","typescript":"^7.0.2","@types/jest":"^30.0.0","@types/react":"19.2.18","react-native":"0.87.1","@types/react-dom":"^19.2.3","semantic-release":"^25.0.7","@testing-library/dom":"^10.4.1","@semantic-release/git":"^11.0.1","@testing-library/react":"^16.3.2","react-native-builder-bob":"^0.43.0","react-native-nitro-modules":"^0.37.0","@semantic-release/changelog":"^7.0.0","conventional-changelog-conventionalcommits":"^10.2.1","@jamesacarr/eslint-formatter-github-actions":"^0.2.0"},"peerDependencies":{"react":"*","react-native":">=0.82.0","react-native-nitro-modules":"*"},"_npmOperationalInternal":{"tmp":"tmp/timbre-player_0.1.0_1787850285713_0.5526936796117166","host":"s3://npm-registry-packages-npm-production"},"react-native-builder-bob":{"output":"lib","source":"src","targets":["commonjs","module",["typescript",{"project":"tsconfig.build.json"}]]}},"0.2.0":{"name":"@afkcodes/timbre-player","version":"0.2.0","description":"React Native audio player built on libmpv, powered by Nitro Modules","main":"./lib/commonjs/index.js","module":"./lib/module/index.js","types":"./lib/typescript/src/index.d.ts","react-native":"src/index","source":"src/index","scripts":{"typecheck":"tsc --noEmit && tsc --noEmit -p tsconfig.test.json","test":"vitest run","test:watch":"vitest","clean":"git clean -dfX","build":"npm run typecheck && bob build","codegen":"nitrogen --logLevel=\"debug\" && npm run build","test:cpp":"cmake -S cpp/tests -B cpp/tests/build && cmake --build cpp/tests/build && ctest --test-dir cpp/tests/build --output-on-failure","pod":"npm --prefix ../../apps/example run pod"},"keywords":["react-native","nitro-modules","libmpv","audio","player"],"repository":{"type":"git","url":"git+https://github.com/afkcodes/timbre.git"},"author":{"name":"Ashish Kumar"},"license":"MIT","bugs":{"url":"https://github.com/afkcodes/timbre/issues"},"homepage":"https://github.com/afkcodes/timbre#readme","publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"devDependencies":{"@jamesacarr/eslint-formatter-github-actions":"^0.2.0","@testing-library/dom":"^10.4.1","@testing-library/react":"^16.3.3","@types/jest":"^30.0.0","@types/react":"19.2.18","@types/react-dom":"^19.2.3","conventional-changelog-conventionalcommits":"^10.2.1","jsdom":"^30.0.1","nitrogen":"^0.37.0","react":"19.2.8","react-dom":"19.2.8","react-native":"0.87.1","react-native-builder-bob":"^0.43.0","react-native-nitro-modules":"^0.37.0","typescript":"^7.0.2","vitest":"^4.1.10"},"peerDependencies":{"react":"*","react-native":">=0.82.0","react-native-nitro-modules":"*"},"eslintConfig":{"root":true,"extends":["@react-native","prettier"],"plugins":["prettier"],"rules":{"prettier/prettier":["warn",{"quoteProps":"consistent","singleQuote":true,"tabWidth":2,"trailingComma":"es5","useTabs":false}],"@typescript-eslint/no-unused-vars":["error",{"ignoreRestSiblings":true,"argsIgnorePattern":"^_","varsIgnorePattern":"^_"}]}},"eslintIgnore":["node_modules/","lib/","android/build/","plugin/build/","nitrogen/","cpp/tests/build/"],"prettier":{"quoteProps":"consistent","singleQuote":true,"tabWidth":2,"trailingComma":"es5","useTabs":false,"semi":false},"react-native-builder-bob":{"source":"src","output":"lib","targets":["commonjs","module",["typescript",{"project":"tsconfig.build.json"}]]},"_id":"@afkcodes/timbre-player@0.2.0","gitHead":"bba8ca107730e9cf4b181d211306de1b00cae25d","_nodeVersion":"22.23.2","_npmVersion":"10.9.8","dist":{"integrity":"sha512-ozQgosdNUvsA//EDK7OVmVZL4rb+2DtQgTy8U+H80ktAYvfCAH/WSOhpwcdECqbYOOKtliI52iSJy5iE7z7t2Q==","shasum":"1fe49334bd39078f938fc20abc9a305f9663c283","tarball":"https://registry.npmjs.org/@afkcodes/timbre-player/-/timbre-player-0.2.0.tgz","fileCount":266,"unpackedSize":2763730,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@afkcodes%2ftimbre-player@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDi11U1Iqj/l+ZuJC396GCB2Cy1NvgkCZX1oBlCOC/XTAIgYWp8FRjQb297txhKNQY0CTbS2fpwxu5fFrJknON4VGg="}]},"_npmUser":{"name":"afkashish","email":"ashish.kmr@outlook.com"},"directories":{},"maintainers":[{"name":"afkashish","email":"ashish.kmr@outlook.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/timbre-player_0.2.0_1788177757653_0.5600616504157794"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-27T17:04:45.519Z","modified":"2026-08-31T12:02:38.345Z","0.1.0":"2026-08-27T17:04:45.911Z","0.2.0":"2026-08-31T12:02:37.802Z"},"bugs":{"url":"https://github.com/afkcodes/timbre/issues"},"author":{"name":"Ashish Kumar"},"license":"MIT","homepage":"https://github.com/afkcodes/timbre#readme","keywords":["react-native","nitro-modules","libmpv","audio","player"],"repository":{"type":"git","url":"git+https://github.com/afkcodes/timbre.git"},"description":"React Native audio player built on libmpv, powered by Nitro Modules","maintainers":[{"name":"afkashish","email":"ashish.kmr@outlook.com"}],"readme":"# @afkcodes/timbre-player\n\nReact Native audio player built on libmpv, powered by Nitro Modules. One mpv\ncore per `Player`, a gapless queue, a typed filter/EQ chain, a spectrum\nvisualizer, and a raw escape hatch onto mpv's client API.\n\n[![Version](https://img.shields.io/npm/v/@afkcodes/timbre-player.svg)](https://www.npmjs.com/package/@afkcodes/timbre-player)\n[![License](https://img.shields.io/npm/l/@afkcodes/timbre-player.svg)](https://github.com/afkcodes/timbre/blob/main/LICENSE)\n\n## Requirements\n\n| | |\n|---|---|\n| React Native | **>= 0.82** (New Architecture only); Node >= 18 |\n| Android | minSdk 24. This package's manifest merges no permissions — not even `RECORD_AUDIO` |\n| iOS | Add `UIBackgroundModes: audio` to your `Info.plist`, or install `@afkcodes/timbre-media-session` and let its Expo plugin merge it. This package configures no `AVAudioSession`. Install [`@afkcodes/timbre-audio-session`](../audio-session/README.md) or write your own session code, or the process default category applies and background audio does not work. Pass `audiounit-skip-session-management=no` in `mpvOptions` to hand the job back to the engine ([ARCHITECTURE §26](../../ARCHITECTURE.md#26-avaudiosession-has-exactly-one-owner-and-it-is-not-the-engine)) |\n\n## Installation\n\n```bash\nnpm install @afkcodes/timbre-player react-native-nitro-modules\n```\n\n## Usage\n\n```tsx\nimport { Button } from 'react-native';\nimport { usePlayer, usePlayerState, useProgress } from '@afkcodes/timbre-player';\n\nfunction Screen() {\n  const { player } = usePlayer({ volume: 0.8, setup: p => p.load('https://x/a.flac') });\n  const { playing } = usePlayerState(player);\n  const { position, duration } = useProgress(player);\n  return <Button title={playing ? 'Pause' : 'Play'} onPress={() => player?.toggle()} />;\n}\n```\n\nOutside React, `await Player.create(options)` gives the same object, and\n[the recipes](../../docs/recipes/music-player.md) build whole apps on it.\n\n## API\n\n### Lifecycle and loading\n\n| | what it does | notes |\n|---|---|---|\n| `Player.create(options?: PlayerOptions): Promise<Player>` | Builds one mpv core and initialises it | Options out of mpv's range throw before a core is created. Multiple players are first-class: one mpv core each |\n| `player.destroy(): void` / `player.destroyed: boolean` | Terminates the core and frees everything | Idempotent; every method afterwards throws `disposed` |\n| `usePlayer(options?: UsePlayerOptions)` | `create` on mount, `destroy` on unmount | `options.setup?: (p: Player) => void \\| Promise<void>` runs once, after create |\n| `createMpvClient(): MpvClient` | The raw binding, with no `Player` wrapper | [docs/engine.md](../../docs/engine.md#reaching-into-mpv) |\n| `load(source, options?: LoadOptions): Promise<void>` | Replaces the queue with one entry | `{ autoPlay?, startPosition?, headers?, mpvOptions? }`. `startPosition` is applied by mpv at open, so there is no audible jump |\n| `loadPlaylist(sources, options?: LoadPlaylistOptions)` | One mpv playlist — this is what gapless *is* | adds `{ startIndex?, shuffle? }`; the two together throw, because after a whole-list shuffle an index no longer identifies the source you passed |\n| `setSourceResolver(resolver \\| null): void` | Install, replace or remove the resolver at any time | `(req: { uri, entryId? }) => string \\| Promise<string>` |\n| `setScreenStateSource(source)` / `getScreenStateSource()` | Replace the display-state signal the visualizer gates on | For an external display or a head unit — not for turning the gate off |\n\n`PlayerOptions`:\n\n| option | default | notes |\n|---|---|---|\n| `volume`, `rate`, `pitch`, `muted`, `loop` | mpv's | `volume` is `0..1` |\n| `cacheSecs` | `30` | mpv's own default is ~1000 hours, which on a radio stream downloads for hours even while paused. Startup is unaffected: `cache-pause-initial` is `no` |\n| `prefetchPlaylist` | `false` | Opens the next entry as soon as the current one is fully read. 25 ms handover with it; 644 ms and a logged device underrun without |\n| `gaplessAudio` | `'weak'` | `'weak'` keeps the device open while the output format matches; `'yes'` always, resampling later entries into the first entry's format; `'no'` never. Pin `--audio-samplerate`/`--audio-format` if you choose `'yes'` |\n| `userAgent` | `timbre (libmpv)` | Real Shoutcast hosts reject the literal `libmpv` |\n| `replayGain` | off | `{ mode, preamp?, clip?, fallback? }` — see [Audio processing](#audio-processing) |\n| `networkReconnect` | on | `{ enabled?, maxDelaySeconds? }` — FFmpeg's own retry, inside libavformat |\n| `retry` | `{ maxAttempts: 2 }` | `{ maxAttempts, retryLiveEof? }` — whether the queue moves on |\n| `sourceResolver`, `resolverTimeoutMs`, `resolverTtlMs` | none, `10_000`, `600_000` | See [Dynamic source resolution](#dynamic-source-resolution-signed-urls-transcode-sessions) |\n| `logLevel`, `mpvOptions` | | Raw `mpvOptions` always win. Precedence at init, weakest first: library defaults, typed options, `mpvOptions`. `profile` and `include` are unsupported there — set them after creation |\n| per-source `headers` | none | The typed form of mpv's `http-header-fields`, escaped through both of mpv's list layers and rejecting CR/LF/NUL/colon, which mpv would write into the request verbatim. It belongs to the **entry**, so a resolver rewriting the URL does not lose it. `.m3u8`/`.m3u` sources additionally force a caller-overridable `demuxer=lavf`, so mpv's playlist demuxer cannot explode your queue into segment entries |\n\n### Playback\n\n| | what it does | notes |\n|---|---|---|\n| `play()` / `pause()` / `toggle()` | | Synchronous — they set mpv's `pause` property |\n| `isPlaying(): boolean` | `state.playing` as a method | What makes a `Player` satisfy `audio-session`'s player contract |\n| `seekTo(seconds): Promise<void>` | Absolute seek | |\n| `seekBy(delta): Promise<void>` | Relative seek | Immune to projection error — use it for ±15 s buttons |\n| `stop(options?: { clearPlaylist? }): Promise<void>` | Stops playback; **the queue survives** | mpv's own `stop` clears it; this library inverts that default. `{ clearPlaylist: true }` is the destructive opt-in |\n| `setRate(rate): void` | Pitch-corrected speed | mpv's `scaletempo2` sits downstream of the filter chain, so rate, filters and ReplayGain compose freely |\n| `setPitch(ratio): void` | Transpose, independent of rate | A frequency **ratio**, not semitones: `2 ** (n / 12)` is the twelve-tone version |\n| `getVolume()` / `setVolume(v)` / `setMuted(muted)` | Volume is `0..1` | mpv's own curve is `gain = (volume / 100) ** 3`. Use `setPropertyNumber('volume', …)` for its amplification range |\n| `setLoop(mode)` / `setAudioChannels(mode)` | `'off' \\| 'track' \\| 'playlist'`; `'auto-safe' \\| 'auto' \\| 'stereo' \\| 'mono'` | The second is an accessibility downmix; `'auto-safe'` restores |\n| `setPrefetchPlaylist(enabled): void` | The runtime twin of the create option | Takes effect from the next prefetch decision; turning it off does not abort a running opener |\n\n### Queue (`player.playlist.*`)\n\n| | what it does | notes |\n|---|---|---|\n| `entries(): readonly PlaylistEntry[]` | `{ uri, entryId, current }[]`, read from mpv | One synchronous node read, constant whatever the length. A pull, not a subscription — call it when something says the queue moved |\n| `add(source, options?: PlaylistAddOptions)` | Insert one entry | See the position table below |\n| `remove(index)` / `move(from, to)` | | `move` has ordinary array semantics |\n| `jumpTo(index, options?: { autoPlay? })` | Play that entry | Clears `pause` by default, like `load()`. `{ autoPlay: false }` stays paused |\n| `next()` / `previous(options?: { restartThreshold? })` | The ⏭ / ⏮ buttons | Past the threshold (3 s), `previous` restarts the track instead of moving back |\n| `clear()` | | Keeps the entry that is playing |\n| `shuffle(): Promise<readonly PlaylistEntry[]>` | mpv `playlist-shuffle` | Permutes **every** entry including the playing one, so you get a `trackChanged` for a track that did not change. Returns the new order |\n| `unshuffle(): Promise<readonly PlaylistEntry[]>` | Undoes it — **once** | mpv keeps one level of history, and each shuffle overwrites it. To restore a user-visible order after several shuffles, keep it yourself and rebuild with `loadPlaylist` |\n\n`PlaylistAddOptions` adds `position` and `play` to the per-source options:\n\n| `position` | where the entry lands |\n|---|---|\n| omitted | the end of the queue |\n| `'next'` | immediately after the current entry |\n| `number` | that exact index; outside `0 … count` it **throws** rather than clamping, because mpv's own behaviour there is to silently append |\n| plus `play: true` | mpv's `*-play` variant: start playback *if nothing is currently playing*. Not \"play this now\" — for that, add the entry and `jumpTo` it |\n\n| Constraint | Detail |\n|---|---|\n| Insertion is one command, not two | `position` compiles onto mpv's own `insert-next` / `insert-at` load actions, so the queue is never briefly wrong the way an append-then-move pair leaves it. Key your own metadata on `entryId`, never on the array index: an insert renumbers everything after it, and `entryId` is unique for the life of the core |\n| `queueChanged.reason` is `'resized'` or `'reordered'` | A reorder issued through the raw `command()` escape hatch is invisible to the event, because a reorder changes no observable mpv property — the price of not streaming the queue across the bridge |\n| An insert during an in-flight prefetch is not itself prefetched | The insert is correct; it costs the prefetch in flight, and the boundary opens cold. Insert well before the current track ends |\n\n### State, metadata and chapters\n\n| | what it does | notes |\n|---|---|---|\n| `player.state: PlayerState` | Immutable snapshot | `status`, `playing`, `duration`, `isLive`, `rate`, `pitch`, `volume`, `muted`, `loop`, `playlist: { index, count }`, `hasNext`, `hasPrevious`, `chapter`, `title`, `seeking`, `seekable`, `bufferedPosition`, `bufferingPercent`, `positionAnchor`, `positionAnchorMs`, `error`, … |\n| `onStateChange(fn): Unsubscribe` | Fires only on real changes | |\n| `getPosition()` / `resyncPosition()` | Seconds, **projected locally**; the second re-reads mpv and re-anchors | No bridge traffic, no timers |\n| `clearError(): boolean` | Dismiss `state.error` | Clears state only — it never suppresses or replays an event |\n| `getMetadata()` / `getMetadataValue(key)` / `getCommonMetadata()` | mpv's typed tag map, one case-insensitive tag, or the normalised set | One node read, no string parsing, and the map cannot mix two tag generations. `'icy-title'` is how radio now-playing arrives |\n| `getChapters()` · `setChapter(index)` · `nextChapter()` · `previousChapter()` | `{ title?, start }[]` with `start` in seconds, plus navigation | Podcasts, m4b audiobooks; `[]` when there are none, never an error |\n\n**Position is an anchor, never a stream.** `positionAnchor` is\n`{ position, timestamp, rate }` in **seconds**, updated only on discontinuities;\nevery surface projects `position + elapsed × rate` locally. `positionAnchorMs` is\nthe same fact in `media-session`'s `{ value, at, rate }` milliseconds with\n`rate: 0` already applied — broadcast that one\n([ARCHITECTURE §7](../../ARCHITECTURE.md#7-position-is-never-streamed--anchors--projection)).\n\n### Hooks\n\nAll take `Player | undefined`, so they are safe before `create()` resolves.\n\n| | returns |\n|---|---|\n| `usePlayer(options?)` | `{ player, error }` |\n| `usePlayerState(player)` / `usePlayerState(player, selector, isEqual?)` | the whole `PlayerState`, or one derived slice that re-renders only when it changes |\n| `useProgress(player, intervalMs = 250)` | `{ position, duration, buffered, isLive }`; the interval stops whenever playback is not advancing |\n| `useMilestones(player, onMilestone, { marks = [25,50,75,90], intervalMs? })` | nothing — calls back at the scrobbling marks. A hook rather than a player timer, because JS timers freeze with the screen off |\n| `usePrefetchStatus(player)` | `{ active: false } \\| { active: true, uri, entryId?, at }` |\n| `useEqualizer(player, options?)` | `Equalizer` — see [below](#audio-filters-and-eq) |\n| `useVisualizer(player, options?, enabled?, pauseWhenInactive?)` | `{ frame, error, active }` |\n\n### Events\n\n`player.on(name, listener): Unsubscribe`.\n\n| event | payload |\n|---|---|\n| `trackChanged` / `trackEnded` | `{ index, previousIndex }` / `{ index }` — the second means finished naturally |\n| `queueEnded` | *(none)* — playback ran off the end |\n| `queueChanged` / `chapterChanged` | `{ count, reason: 'resized' \\| 'reordered' }` / `{ index?, previousIndex? }` |\n| `seekStarted` / `seekCompleted` | `{ reason: 'seek' \\| 'auto-advance', from }` / `{ …, position }` — the pair analytics reconstructs listened time from, and it arrives with the screen off |\n| `metadataChanged` | `Metadata` — at most once per native event batch, and only while something is listening |\n| `prefetchStarted` | `{ uri, entryId? }` — fires seconds into the current track, when mpv releases its opener thread. Needs no resolver, and never occurs on a build without the fork's hook |\n| `retrying` / `error` | `{ index, attempt, maxAttempts, error }` / `(error: PlayerError, info: { attempts })` |\n| `log` | `{ level, prefix, text }` from mpv itself |\n\n### Audio processing\n\n| | what it does | notes |\n|---|---|---|\n| `setAudioFilters(filters): void` | Compiles typed descriptors into mpv's `af` grammar | Replaces the whole user chain. Rejects a chain carrying an `@rnmedia_…` label, which is reserved for managed entries |\n| `clearAudioFilters()` / `getAudioFilters(): string` | Clear the user chain, or read the compiled chain as mpv sees it | `clearAudioFilters` leaves the managed loudnorm entry alone |\n| `setAudioFilterParam(filter, param, value)` | mpv's `af-command`: change one value without rebuilding the chain | Needs a labelled entry. `AUDIO_FILTER_RUNTIME_PARAMS` lists what can change this way; `diffAudioFilterParams(from, to)` answers \"same graph, different numbers?\" |\n| `setReplayGain(options): void` / `getReplayGainMode()` | Volume-domain gain from the file's tags: `{ mode: 'no' \\| 'track' \\| 'album', preamp?` (dB, `-150 … 150`)`, clip?, fallback?` (dB, `-200 … 60`)` }` | Applies to the *playing* track with no gap and no reload. Only the fields you pass are written, and out-of-range values throw instead of being clamped. `clip` means \"**allow** clipping\": peak limiting is on by default and `clip: true` turns it off. `fallback` replaces the tag logic for untagged files *and* whenever `mode` is `'no'`, so pass `{ mode: 'no', fallback: 0 }` to return to unity gain |\n| `setLoudnessNormalization(enabled, options?)` / `getLoudnessNormalization()` | A managed `loudnorm` entry for files with no tags | `{ targetLufs?, loudnessRange?, truePeakDb?, dualMono? }` |\n| `AudioFilters.*` | `equalizer`, `bass`, `treble`, `lowpass`, `highpass`, `graphicEqualizer`, `crossfeed`, `compressor`, `limiter`, `dynamicNormalizer`, `loudnorm`, `volume`, `custom` | `custom` reaches any ffmpeg audio filter compiled in; `GRAPHIC_EQUALIZER_BANDS` is the 18-band centre list |\n| `EQUALIZER_PRESETS` / `EQUALIZER_PRESET_LIST` / `EQUALIZER_BANDS` | 22 tuned 10-band curves; the list is picker-ordered; the bands are the ISO centres | `EQUALIZER_BAND_COUNT` is 10 |\n| `equalizerPresetChain(preset, options?)` | Preset → filter chain with a computed pre-amp | `{ editable: true }` labels every band so `setAudioFilterParam` can find it, and emits all ten bands so the chain's shape never depends on the gains |\n| `defineEqualizerPreset(id, name, gainsDb)` | Validates a user-designed curve | Exactly 10 gains |\n| `serializeEqualizerSettings` / `parseEqualizerSettings` | The on-disk form `useEqualizer`'s `storage` writes | Parse returns a typed `EqualizerRestoreResult` and never throws |\n| `player.visualizer.capabilities` | `{ fft, waveform, maxFps, minFftSize, maxFftSize }` | `fft: false` on binaries without the PCM-tap patch |\n| `player.visualizer.subscribe(listener, options?)` | Imperative spectrum | Never auto-paused — the hook is what pauses |\n\nReplayGain and `setLoudnessNormalization` are **mutually exclusive** and the API\nenforces it, because their gains would stack. Enabling either disables the other.\n\n### Escape hatch\n\n`command` · `getPropertyString` / `Number` / `Bool` · `setPropertyString` /\n`Number` / `Bool` · `observeProperty` · `unobserveProperty` ·\n`getRawHandle(): bigint`. A complete raw mpv client, with two documented limits:\nan extra observed property does not become a `Player` event, and video stays out\n([docs/engine.md](../../docs/engine.md#reaching-into-mpv)).\n\n## Audio filters and EQ\n\n```ts\nimport { AudioFilters, EQUALIZER_PRESETS, equalizerBandLabel, equalizerPresetChain } from '@afkcodes/timbre-player'\n\nplayer.setAudioFilters([AudioFilters.bass({ gain: 4 }), AudioFilters.crossfeed({ strength: 0.3 })])\n\n// A slider commands the running filter instead of rewriting the chain.\nconst chain = equalizerPresetChain(curve, { editable: true })\nplayer.setAudioFilters(chain)\nconst band = chain.find((f) => f.label === equalizerBandLabel(5)) // the 1 kHz band\nif (band !== undefined) await player.setAudioFilterParam(band, 'g', -3)\n```\n\nEach factory validates against the underlying filter's ranges and throws\n`invalid-state` rather than letting mpv fail later.\n\n| Constraint | Detail |\n|---|---|\n| `af` is a global mpv option, not per-entry | A chain survives track changes by design, and unmounting an EQ screen does not clear it. `setEnabled(false)` takes it off |\n| A preset's pre-amp comes from the summed magnitude response | Octave-spaced bells overlap and add, so attenuating by the largest slider would still clip. Bands at 0 dB are dropped, and a flat preset compiles to an empty chain |\n| Every non-flat curve gets an `alimiter` on the tail | The pre-amp bounds gain in the frequency domain; clipping is a time-domain event, so the limiter is where the guarantee comes from (`{ limiter: false }` opts out). It costs 5 ms of uncompensated look-ahead: 5 ms of silence when the chain is built, and the last 5 ms of the stream is never flushed. ffmpeg's `level` option also scales the result back up to full scale — pass `autoLevel: false` with any ceiling below 1 |\n| `setAudioFilters` recreates every entry whose arguments changed | Fine for a settings change, ruinous for a slider. Use `setAudioFilterParam` during a gesture and write the chain once it is over |\n| `getAudioFilters()` shows the value an entry was created with | `af-command` deliberately does not rewrite the property, so anything that rebuilds the chain from it puts the old value back |\n| The chain order is equaliser → your chain → the managed loudness entry | The pre-amp must attenuate before the boosts it is sized for; the loudness entry must hear everything above it |\n| Availability | The filters are in the pinned binaries on both platforms. On an older or overridden binary the call fails with an `mpv` `PlayerError` carrying `errno: -11` |\n\nDesign rationale: [ARCHITECTURE §18](../../ARCHITECTURE.md#18-eqdsp-is-a-typed-chain-over-mpvs-af-not-a-native-module).\n\n### `useEqualizer()`\n\nThe whole EQ screen on one hook ([worked\nexample](../../docs/recipes/music-player.md#an-eq-screen-over-the-same-player)).\nReturns `{ enabled, bands, gainsDb, gainRangeDb, preset, presets, savedPresets,\nerror, hydrated }` plus `setEnabled`, `setBandGain`, `setBandGains`,\n`applyPreset`, `reset`, `savePreset`, `deletePreset`. Options: `initialPreset`,\n`initialEnabled`, `chain`, `gainRangeDb` (±12 dB), `storage`, `storageKey`,\n`onStorageError`, and the deprecated `extraFilters`.\n\n| Behaviour | Detail |\n|---|---|\n| `preset` is derived, not remembered, and there is no change event | `preset` is whichever preset the current gains *are*, and `undefined` is what a UI draws as \"Custom\". The returned object is the notification: every mutator re-renders with a fresh immutable snapshot |\n| A gain-only change is pushed with `af-command` | No chain rebuild and no click, whatever the frame rate of the gesture. A graph change is one `setAudioFilters` |\n| 250 ms after the last in-place change, one `setAudioFilters` commits | So the curve survives the next track or device switch. That is the hook's only timer, and it is flushed on unmount |\n| The hook owns only its own `@rnmedia_eq_…` entries | Your `setAudioFilters` chain survives a slider drag |\n| Gains are clamped; wrong lengths and non-finite values throw | A slider at its stop is not a bug; ten gains that are not ten gains is |\n| `storage` is injected, and this package depends on none | A **synchronous** engine is read through synchronously, so the first `af` write is already the restored curve; an **asynchronous** one leaves `hydrated` `false` and writes nothing to mpv until the record arrives |\n| Built-in presets are never persisted | So a release that retunes `Rock` takes effect. A corrupt record means \"start from the defaults\"; a *storage* failure reaches `onStorageError` |\n| `savePreset(name)` joins `presets` | Saving twice under one name replaces it; `deletePreset(id)` throws on a built-in id and no-ops on an unknown one |\n\n## Visualizer (spectrum + waveform)\n\n```ts\nif (player.visualizer.capabilities.fft) {\n  const stop = player.visualizer.subscribe(\n    (frame) => paint(frame.bands),  // Float32Array, [0, 1], already smoothed\n    { bands: 32, fps: 30, waveform: false }\n  )\n  stop() // disarms mpv's tap\n}\n```\n\n`useVisualizer(player, { bands: 28 })` is the React form.\n\n| | Detail |\n|---|---|\n| A `VisualizerFrame` carries | `bands` (log-spaced, dB-mapped, asymmetrically smoothed), `peaks` (peak-hold caps that snap up, hang for `peakHoldFrames`, then fall under accumulating `peakGravity`), `magnitudes` (raw per-bin linear magnitudes, `fftSize / 2 + 1` long, for your own mel/Bark/chroma mapping), `gainDb`, and — only with `{ waveform: true }` — `waveform`, `peak` and `rms` |\n| Both platforms, one code path, no permission | The samples come from mpv itself through a `pcm-tap` patch in both binary forks. There is no `Platform.OS` in the feature and nothing to add to your manifest |\n| It taps what you hear, and it needs binaries carrying the patch | The tap sits where mpv hands audio to the device, after the filter chain and after mpv's software gain. On a binary without it, `capabilities.fft` is `false` and `subscribe()` throws a typed `unsupported` error — it never silently does nothing |\n| 30 fps by default, 60 available | That is the *delivery* rate; new spectral content arrives no faster than the audio device consumes chunks. `frame.dropped` tells you when painting cannot keep up — measure it in a release build |\n| Zero cost when nobody is looking | The tap is disarmed and its write path is one atomic load per device chunk; the first `subscribe()` creates the ring, the FFT tables and the sampler thread, and the last unsubscribe frees them |\n| The FFT is native, the optics are yours | PCM never crosses into JavaScript; ~4 KB of spectrum per frame does. Bands, dB window, tilt, auto-gain, smoothing and peak ballistics are TypeScript, per subscriber. `fftSize`, `fps` and `waveform` are the union across live subscribers; everything else is per subscriber |\n| `tiltDbPerOctave` and `autoGain` default to off | Faithful by default. Auto-gain is bounded to −6…+18 dB, backs off four times faster than it builds, and holds still below −95 dBFS so it cannot amplify a noise floor |\n| The hook pauses itself when nothing can be seen | Frames are native callbacks, so unlike a JS timer they keep arriving behind a locked screen. \"Can be seen\" is `AppState` **and** the device display state, ANDed, because on Android those are different facts. Resuming is a fresh subscription, so bars rise from zero. Audio is unaffected |\n\nOpt out of the pause with `useVisualizer(player, options, true, false)`, or\nsubscribe imperatively, which is never `AppState`-gated. The hook re-renders at\nthe frame rate, so keep it in a small leaf component\n([ARCHITECTURE §21](../../ARCHITECTURE.md#21-the-visualizer-taps-mpv-itself--we-patched-libmpv-rather-than-ship-an-android-only-feature)).\n\n## Dynamic source resolution (signed URLs, transcode sessions)\n\nFor queues whose entries cannot be written down ahead of time: a signed CDN link\nthat expires in minutes, a transcode session created per track.\n\n```ts\nimport { Player, type SourceResolver } from '@afkcodes/timbre-player';\n\nconst resolve: SourceResolver = async ({ uri }) => {\n  if (!uri.startsWith('library://')) return uri;                    // pass through\n  return (await api.signPlaybackUrl(uri.slice('library://'.length))).url;\n};\nconst player = await Player.create({ prefetchPlaylist: true, sourceResolver: resolve });\nawait player.loadPlaylist(['library://a', 'library://b', 'library://c']);\n```\n\n| Constraint | Detail |\n|---|---|\n| Nothing about the URL changes until you install a resolver | The two mpv load hooks are registered when the core starts, but the handler is disarmed: it reads nothing and continues the hook immediately, so what mpv opens is byte-for-byte the URI you queued |\n| Resolution runs ahead | The current and next entries are resolved as the queue moves, read from mpv's own playlist, so it follows `next()`, repeat and shuffle. A resolved entry costs a map lookup and one property write |\n| Only a play-time miss holds mpv | For up to `resolverTimeoutMs`; on timeout the original URI is used and mpv fails the load on its own terms, arriving as an ordinary typed `error` |\n| Your resolver must be deterministic while an entry is queued | mpv opens each entry twice and reuses the prefetched stream only if the two URLs are byte-identical, so a fresh nonce per call defeats prefetching and the boundary opens cold. One answer per URI is cached and replayed for `resolverTtlMs`: mint once per track, and size the TTL to cover a track while staying inside your signature's lifetime |\n| A resolver that throws, rejects or returns a non-string | Emits a typed `load-failed` error, caches nothing, and is retried on the next queue movement |\n| The prefetch half needs our forks | Upstream mpv deliberately does not fire a hook there. On a stock libmpv the play-time half still works and prefetched entries open unresolved |\n\n## Two routes to a title, and which one you want\n\n| | `state.title` | `metadataChanged` + `getMetadataValue()` |\n| --- | --- | --- |\n| shape | one coalesced string | the whole tag map, or one key |\n| delivery | in **every snapshot** — rides the state fan-out, and therefore every broadcast channel and the media session | an **event**, and only while something is listening |\n| cost | none beyond the snapshot | one node read per batch that touched the tags |\n| on a radio stream | the currently-playing **song**, updating on its own | the **station**: `icy-name`, `icy-genre`, `icy-br`, … |\n\nUse `state.title` for the now-playing line: a media session re-broadcasts state\nrather than events, so a title delivered only as an event would never reach the\nnotification. Use `metadataChanged` for specific keys — building the map is a\nsynchronous read into mpv's core, so it is opt-in.\n\n## Recovering from network failures\n\nTwo layers, answering different questions.\n\n**1. FFmpeg reconnection (`networkReconnect`, on by default)** answers \"can this\nconnection be re-made\". It runs inside libavformat's read loop with no JavaScript\nand no timers — the only kind of retry that works with the screen off. Through\nmpv's `stream-lavf-o` it sets `reconnect=1`, `reconnect_on_network_error=1`,\n`reconnect_streamed=1` and `reconnect_delay_max=5`; FFmpeg's backoff is\n`delay = 1 + 2 * delay` from `0`, so `5` means attempts at 0 s, 1 s and 3 s. Opt\nout with `{ enabled: false }`, widen it with `{ maxDelaySeconds: 20 }`.\n\n`reconnect_at_eof` is deliberately not set: FFmpeg does not guard it on whether\nthe stream is seekable, so on a sized file the natural end of the response *is*\n`AVERROR_EOF`, and enabling it turns every clean track end into a retry storm\nending in `EIO`. HTTP status codes are not retried either — that is\n`reconnect_on_http_error`, a policy an app chooses for itself. A live-only app\ncan opt in by replacing the whole list through `mpvOptions['stream-lavf-o']`.\n\n**2. Player-level re-attempt (`retry`, 2 attempts by default, `0` disables)**\nanswers \"should the queue move on?\". mpv's own behaviour on a hard failure is to\nadvance, which is right for a file that will never play and wrong for a stream\nthat was unlucky.\n\n| Constraint | Detail |\n|---|---|\n| `error` counts give-ups, not failures | A retryable failure jumps back to the entry, preserves whether it was playing, and emits `retrying`; no `error` is emitted for that attempt |\n| There is no delay between attempts | The only way to wait in JavaScript is a timer, and JS timers freeze with the screen off — spaced backoff is layer 1's job. The re-attempt is issued after mpv already started the next entry, so a failure at a queue boundary can produce a brief blip of the following track |\n| Attempts are tracked per entry generation | Reset when the entry plays, when a different entry fails, or when the app moves the cursor or edits the queue |\n| `retryLiveEof` (off) re-attempts a clean close on a **live** entry | A finite track is never affected. The re-attempt emits `retrying` with a synthesised `network` error and no `trackEnded`; once the budget is spent the end is reported as the `trackEnded` it always was. The budget refills only after 30 s of playback, so a server hanging up every second cannot loop forever |\n\n## Errors\n\nEvery `PlayerError` carries `retryable: boolean` — \"could repeating the identical\noperation plausibly succeed with nothing else changed?\". Read that rather than\nmaintaining a table of codes; it is also what `retry` consumes. `network` is\n`true`, and so is `mpv` with `errno: -14` (`AO_INIT_FAILED`), because the audio\ndevice is shared and a route change can lose you one open and not the next.\nEverything else is `false`. A natural end of stream is a `trackEnded` event and\nnever an error; `PlayerErrorException` is the thrown form. `state.error` clears\non a new entry starting, on playback restarting, or on a deliberate stop, and\nsurvives only when the last entry failed and nothing has happened since — which\nis what `clearError()` is for.\n\n## Platform parity\n\nEvery public member behaves identically on Android and iOS except the rows below.\nThere is no member that exists on one platform and quietly does nothing on the\nother.\n\n| Member | Android | iOS | Verdict |\n|---|---|---|---|\n| `getScreenStateSource().interactive` and its subscription | `PowerManager.isInteractive()` + `ACTION_SCREEN_ON`/`OFF` | constant `true`, subscription never fires | **ceiling** — locking an iPhone resigns active state, so `AppState` already *is* the display truth, and there is no public API for display power |\n| Background playback setup | nothing to add | `UIBackgroundModes: audio` in your `Info.plist` | **setup differs** |\n| `content://` sources | `ContentResolver` → mpv `fd://`, transparently | n/a — iOS has no equivalent scheme | **parity** |\n| Verification coverage | device-verified | CI build plus shipped-binary inspection, simulator slice only | **coverage differs** |\n\nThe surface is symmetric by construction: everything from playback to the error\ntaxonomy lives in one shared C++ core over libmpv's client API, with no platform\nbranch and no `ao=` set at all\n([ARCHITECTURE §11](../../ARCHITECTURE.md#11-binaries-pinned-forked-lgpl-dynamically-linked)).\n`content://` URIs play with nothing to configure: `load()`, `loadPlaylist()` and\n`playlist.add()` open them through `ContentResolver` and hand mpv its own `fd://`\nprotocol, once per URI, for the life of the player\n([ARCHITECTURE §32](../../ARCHITECTURE.md#32-content-is-a-binder-call-so-it-becomes-a-file-descriptor)).\n\n| `content://` constraint | Detail |\n|---|---|\n| Seeking follows the provider | mpv decides seekability by `lseek`-ing the descriptor, so a file-backed URI is seekable and reports a duration while a pipe-backed one surfaces exactly like a live stream |\n| The grant is yours to keep | A picker's read permission dies with the process; call `takePersistableUriPermission()` for a URI you store, or the next load fails with a typed `load-failed` |\n| Never queue one `content://` URI at two adjacent positions | One descriptor serves one URI, so the playing and prefetched entries would share a file offset |\n\n## Also exported\n\n| Group | Exports |\n|---|---|\n| Error taxonomy | `PlayerError` — the union `NetworkError \\| UnsupportedFormatError \\| LoadFailedError \\| DisposedError \\| InvalidStateError \\| UnsupportedError \\| RawMpvError`; `PlayerErrorCode`; `PlayerErrorException`; `Retryable`; `toPlayerError(thrown, uri?)`, `isRetryableErrno(errno)`, `isNetworkUri(uri)`; `EndFileOutcome` / `classifyEndFile` |\n| Defaults | `DEFAULT_USER_AGENT` = `'timbre (libmpv)'`, `DEFAULT_CACHE_SECS` = `30`, `DEFAULT_RETRY_MAX_ATTEMPTS` = `2`, `DEFAULT_RECONNECT_DELAY_MAX_SECONDS` = `5`, `DEFAULT_RESOLVER_TIMEOUT_MS` = `10_000`, `DEFAULT_RESOLVER_TTL_MS` = `600_000`, `DEFAULT_VISUALIZER_FPS` = `30` |\n| Option types | `RetryOptions`, `NetworkReconnectOptions`, `SourceResolverOptions`, `VisualizerOptions`, `VolumeOptions`, `GaplessAudioMode`, `ReplayGainMode`, `HttpHeaders` |\n| Equaliser | `EqualizerPreset`, `EqualizerPresetId`, `EqualizerBand`, `EqualizerGainRange`, `EqualizerSettings`, `EqualizerStorage`, `UseEqualizerOptions`, `EQUALIZER_PREAMP_LABEL`, `EQUALIZER_LIMITER_LABEL`, `LOUDNESS_NORMALIZATION_LABEL`, `EQUALIZER_SCHEMA_VERSION`, `DEFAULT_EQUALIZER_STORAGE_KEY` |\n| Filters | `CompressorOptions`, `LimiterOptions`, `LoudnormOptions`, `DynamicNormalizerOptions`, `CrossfeedOptions`, `ShelfOptions`, `PassOptions`, `BiquadWidthType`, `assertValidAudioFilters` |\n| State and events | `PlayerStatus`, `PositionAnchor`, `PositionAnchorMs`, `Progress`, `Milestone`, `PlaylistApi`, `PlaylistPosition`, `PlayerEvent`, `PlayerEventMap`, `PlayerEventName`, the per-event types (`TrackChangedEvent`, `TrackEndedEvent`, `SeekEvent`, `QueueChangedEvent`, `ChapterChangedEvent`, `PrefetchStartedEvent`, `RetryingEvent`, `LogEvent`, …), `PositionDiscontinuityReason`, `QueueChangeReason` |\n| Hooks' result types | `UsePlayerResult`, `UseVisualizerResult`, `PlayerStateSelector` |\n| Visualizer | `VisualizerController`, `VisualizerCapabilities`, `VisualizerCapture`, `VisualizerListener`, `VISUALIZER_DEFAULTS` |\n| Escape hatch | `MpvEvent`, `MpvEventKind`, `MpvProperty`, `MpvPropertyValue`, `MpvFormat`, `MpvLogLevel`, `MpvEndFileReason`, `OBSERVED_PROPERTIES`, `MpvClientFactory` |\n| `content://` (Android) | `isContentUri(uri)`, `ContentUriResolver` (the built-in rewrite), `ContentUriOpener` / `getContentUriOpener()` / `setContentUriOpener(fn)` (override the fd source, e.g. a SAF grant), `CONTENT_URI_SCHEME`, `CONTENT_URI_FD_LIMIT` — see [ARCHITECTURE §32](../../ARCHITECTURE.md#32-content-is-a-binder-call-so-it-becomes-a-file-descriptor) |\n| Audio-processing types | `AudioFilter`, `EqualizerPresetChainOptions`, `ReplayGainOptions`, `LoudnessNormalizationOptions`, `AudioChannelMode` (`'auto-safe' \\| 'auto' \\| 'stereo' \\| 'mono'`), `MANAGED_FILTER_LABEL_PREFIX` (the reserved `rnmedia_` prefix `setAudioFilters` rejects) |\n| Metadata and chapters | `CommonMetadata`, `ChapterEntry`, `LoopMode` (`'off' \\| 'track' \\| 'playlist'`) |\n| Pure internals, exported for tests — **not API**, and carrying no stability promise | the reducer and its helpers (`createInitialState`, `toPlayerEvent`, `toPlayerEvents`, `ReducerContext`, `withResyncedAnchor`, `isPositionDiscontinuity`, `clearPlayerError`, `disposedError`, `toVisualizerError`, `TrackChangeReads`, `LoopRaw`), the mpv property helpers (`ObservedProperty`, `isMetadataProperty`, `metadataKeyProperty`, `metadataByKeyProperty`, `metadataValueProperty`, `playlistFilenameProperty`, `toCommonMetadata`), filter-string helpers (`escapeAfParam`, `escapeSubparam`, `peakResponseDb`, `compileHttpHeaderFields`, `HTTP_HEADER_FIELDS_OPTION`, `utf8Length`, `AudioFilterOption`, `AudioFilterParamChange`, `EqualizerOptions`, `GraphicEqualizerOptions`), visualizer decoding (`createDecodeState`, `decodeVisualizerFrame`, `resolveVisualizerOptions`, `VisualizerDecodeState`, `VisualizerUnsubscribe`), the raw mpv event types (`StartFileEvent`, `EndFileEvent`, `PlaybackRestartEvent`, `SeekStartedEvent`, `SeekCompletedEvent`, `PropertyEvent`, `ShutdownEvent`), `PrefetchStatus` / `PrefetchIdle` / `PrefetchActive`, `SourceOptions`, `SourceResolutionRequest`, `SourceResolverController`, `PlayerErrorInfo`, `PlayerLogLevel`, and the tuning constants `AGC_SILENCE_DB`, `BUFFERED_POSITION_STEP`, `BUFFERING_PERCENT_STEP`, `DEFAULT_EQUALIZER_GAIN_RANGE_DB`, `DEFAULT_LOUDNESS_TARGET_LUFS`, `DEFAULT_MILESTONES`, `DEFAULT_PROGRESS_INTERVAL_MS`, `DEFAULT_RESTART_THRESHOLD_SECONDS`, `MPV_VOLUME_SCALE` |\n\n## Contributing\n\nSee [`CONTRIBUTING.md`](../../CONTRIBUTING.md). Bootstrapped with\n[create-nitro-module](https://github.com/patrickkabwe/create-nitro-module).\n","readmeFilename":"README.md"}