Skip to main content

Interface: PersistedMediaService

Defined in: packages/media-session/src/persistence.ts:354

MediaServiceApi plus the two hooks persistence needs.

Extends

Methods

cancelSleepTimer()

cancelSleepTimer(): void;

Defined in: packages/media-session/src/types.ts:1002

Disarm the sleep timer. A no-op when none is armed.

Returns

void

Inherited from

MediaServiceApi.cancelSleepTimer


clear()

clear(): Promise<void>;

Defined in: packages/media-session/src/persistence.ts:398

Forget the persisted session — both copies.

clearPersisted only knows about the storage engine it is handed. This also clears the native resumption mirror, which is what would otherwise keep offering the user a System UI resumption card for a session they asked you to forget (a sign-out, a "clear history").

Rejects if the storage engine does; the mirror is cleared only after the storage write lands, so the two never disagree in the direction that matters.

Returns

Promise<void>


flush()

flush(): Promise<void>;

Defined in: packages/media-session/src/persistence.ts:407

Resolves once every write issued so far has settled.

Different from save: save() decides what to write, flush() waits for writes already issued to land. Only meaningful for an asynchronous storage; a synchronous one has always already finished. Never required for correctness — writes are ordered either way.

Returns

Promise<void>


getCarConnection()

getCarConnection(): CarConnection;

Defined in: packages/media-session/src/types.ts:1049

Is a car connected right now?

Synchronous and cheap. The reactive twin is useCarConnection() from @afkcodes/timbre-media-session/hooks, which re-renders on every transition.

Returns

CarConnection

Inherited from

MediaServiceApi.getCarConnection


getSleepTimer()

getSleepTimer(): SleepTimerState | undefined;

Defined in: packages/media-session/src/types.ts:1025

The armed timer's mode, and its remaining seconds when those are knowable — or undefined when nothing is armed.

const timer = service.getSleepTimer()
if (timer?.mode === 'trackEnd') badge(timer.remainingSeconds ?? 'end of track')

Returns

SleepTimerState | undefined

Inherited from

MediaServiceApi.getSleepTimer


getSleepTimerRemaining()

getSleepTimerRemaining(): number | undefined;

Defined in: packages/media-session/src/types.ts:1015

Seconds until the sleep timer fires, or undefined when none is armed.

Synchronous and cheap — meant to be polled by a visible UI, which is the one place JS timers do work. Read from the platform's own timer clock, so it cannot disagree with when the pause will actually happen.

Cannot describe an end-of-track timer with no computable deadline, and returns undefined for one — use getSleepTimer when the difference between "not armed" and "armed, deadline unknown" matters, which for a UI that renders a timer badge it always does.

Returns

number | undefined

Inherited from

MediaServiceApi.getSleepTimerRemaining


invalidateBrowse()

invalidateBrowse(parentId?: string): void;

Defined in: packages/media-session/src/types.ts:1042

The children of parentId changed — a download finished, a library synced, the user signed in.

Evicts the cached answer and tells every connected browser to ask again: Android calls MediaLibrarySession.notifyChildrenChanged, iOS re-fetches and re-sections any visible template showing that parent. Omit parentId for "everything changed", which evicts the whole cache and notifies for every parent in it plus the root.

Cheap and safe to call when nothing is listening: with no car connected it is a cache eviction and nothing else.

Parameters

ParameterType
parentId?string

Returns

void

Inherited from

MediaServiceApi.invalidateBrowse


save()

save(): void;

Defined in: packages/media-session/src/persistence.ts:385

Write a snapshot now, without a broadcast.

Why this exists

The tee saves on every broadcast, and broadcasts are discontinuity-only by design. PersistenceOptions.autosave covers the resulting gap while JavaScript timers run — but on Android they stop the moment the Activity is gone, which is a React Native platform behaviour and not something this package can reach from JavaScript.

So the moment stays the app's to take, and this is how it takes it. Each save() re-projects the live anchor to right now and refreshes the native resumption mirror too, so the record is as fresh as the call. Good moments:

AppState.addEventListener('change', (s) => {
if (s !== 'active') service.save() // leaving the foreground
})

…and onTaskRemoved (the app was swiped away), and just before a deliberate stopService(). The first of those is the important one: it fires at exactly the instant autosave stops being able to fire.

Honest limit: nothing can checkpoint a process that is killed with no warning while playing in the background with no Activity. The position then restores to the last checkpoint — with autosave on, at worst one interval before the app was backgrounded, which is a defensible answer and never a wrong one.

Returns

void


setMediaItem()

setMediaItem(item?: NativeMediaItem): void;

Defined in: packages/media-session/src/types.ts:870

Broadcast channel 2 of 3: what is playing right now. Omit item to clear the metadata.

Channel priority

When a queue is in play, this channel and setQueue both describe the current track, and this one is the more specific statement — it is what you send once the track is actually prepared. So for the current entry only, and only when item.id matches the id at the broadcast queueIndex, this item is merged over the queue entry field by field: a field you set here wins, a field you omit falls back to the queue entry. Every other queue entry is untouched.

In practice this is how duration reaches the lock screen and the notification: apps rarely know durations for queue items up front, so setQueue carries none and the real duration arrives here. Without a duration Android cannot draw a scrubber and iOS treats the track as a live stream.

If item.id does not match the current queue entry, the queue entry wins unchanged (and both platforms log a warning once) — that combination means the two broadcasts have got out of step.

The merge, the priority order and the mismatch rule are identical on Android (Snapshot.timeline / enrichedWith) and iOS (NowPlaying.resolve). In particular, broadcasting only a queue plus a queueIndex is a complete statement on both: the queue entry at that index is what the notification and the lock screen show.

Parameters

ParameterType
item?NativeMediaItem

Returns

void

Inherited from

MediaServiceApi.setMediaItem


setPlaybackState()

setPlaybackState(state: PlaybackState): void;

Defined in: packages/media-session/src/types.ts:840

Broadcast channel 1 of 3.

Parameters

ParameterType
statePlaybackState

Returns

void

Inherited from

MediaServiceApi.setPlaybackState


setQueue()

setQueue(items: NativeMediaItem[]): void;

Defined in: packages/media-session/src/types.ts:878

Broadcast channel 3 of 3: the whole queue, for controllers that render one.

Pair it with queueIndex on setPlaybackState to say which entry is current. Queue entries may be sparse — id and title are usually enough; see setMediaItem for how the current entry gets enriched.

Parameters

ParameterType
itemsNativeMediaItem[]

Returns

void

Inherited from

MediaServiceApi.setQueue


setRemotePlayback()

setRemotePlayback(remote?: RemotePlayback): void;

Defined in: packages/media-session/src/types.ts:945

Say that playback is coming out of another device right now — and hand over that device's volume. Pass nothing when the phone takes it back.

// while the receiver owns playback
service.setRemotePlayback({ volume: receiverVolume, muted: receiverMuted })
// …and when the transfer back completes
service.setRemotePlayback()

Not a fourth broadcast channel — it describes the output, not what is playing — and it is sticky: an ordinary setPlaybackState does not clear it, because "the audio is on the speaker" is a mode rather than a per-broadcast fact. Publish it once when the handoff completes, then again whenever the remote device's volume moves (the backend's own volume events — a speaker's physical knob counts).

What it buys on Android: the volume keys drive the other device

The session starts advertising DeviceInfo.PLAYBACK_TYPE_REMOTE, media3 puts the platform session into remote volume handling (MediaSession.setPlaybackToRemote, whose own documentation says it "must be called to receive volume button events, otherwise the system will adjust the appropriate stream volume for this session"), and hardware volume presses arrive at MediaHandler.onAdjustDeviceVolume — with the app foregrounded, backgrounded, or with the screen off. Clearing it puts the keys back on the phone's own stream; there is no residue.

Two platform preconditions apply to the screen-off case, both of them the platform's rules rather than this library's, and both documented with source citations in the package README ("Two platform conditions"): the session must be actually PLAYING, and no system-uid sound (a notification, a ringtone) may have been the last audio played locally — MediaSessionService prefers the local stream over the chosen session in that case (b/275185436), and since a remote backend plays nothing locally, the press is then dropped by both devices. Foregrounded presses are immune, because an Activity routes them to its own session by token.

Without it the phone's music stream moves while the other device plays on, which is the bug this exists to fix.

iOS: a documented no-op, not a silent one

iOS gives an app no way to take over the hardware volume buttons — MPVolumeView is the system slider, AVAudioSession.outputVolume is read-only, and even Google's Cast SDK documents its physicalVolumeButtonsWillControlDeviceVolume as having no effect from iOS 15 on. So this call changes nothing there. Write it once, unconditionally; it is free on iOS and load-bearing on Android.

Parameters

ParameterType
remote?RemotePlayback

Returns

void

Throws

invalidArgument for a volume outside 0..1, a steps that is not a positive integer, or an unknown volumeControl.

Inherited from

MediaServiceApi.setRemotePlayback


setResumptionSnapshot()

setResumptionSnapshot(snapshot?: string): void;

Defined in: packages/media-session/src/types.ts:892

Mirror the persisted session into native storage so Android can resume playback after the process is killed. withPersistence calls this for you — there is no reason for app code to.

The argument is the serialized PersistedSession record, byte-identical to what went into your storage engine, so the two copies cannot drift. The native side keeps it in its own SharedPreferences, which is the only thing the media service can read when it is created into a process with no JavaScript in it. undefined forgets it.

A no-op on iOS and whenever android.playbackResumption is false.

Parameters

ParameterType
snapshot?string

Returns

void

Inherited from

MediaServiceApi.setResumptionSnapshot


setSleepTimer()

setSleepTimer(seconds: number): void;

Defined in: packages/media-session/src/types.ts:971

Pause playback in seconds, on a native timer. Re-arming replaces any timer already set.

Do not build this on setTimeout: JS timers freeze once the Activity is gone (and on Samsung even before that), which is precisely when a sleep timer has to work. This one is a main-looper Handler.postDelayed on Android and a DispatchQueue.main.asyncAfter work item on iOS, so it is unaffected by the React lifecycle.

When it fires, playback is paused natively — the same path a notification pause takes — and then MediaHandler.onSleepTimer is called. The timer does not survive process death, and is cancelled by stopService and by a dev reload.

Parameters

ParameterTypeDescription
secondsnumberstrictly positive and finite.

Returns

void

Throws

invalidArgument for 0, negatives, NaN, Infinity or a non-number.

Inherited from

MediaServiceApi.setSleepTimer


setSleepTimerToTrackEnd()

setSleepTimerToTrackEnd(): void;

Defined in: packages/media-session/src/types.ts:1000

Pause when the current item finishes, on the same native timer. Replaces any timer already armed.

A separate method rather than an option object on setSleepTimer: the two modes take different arguments (one takes seconds, this takes nothing), and a setSleepTimer(number | {atTrackEnd:true}) union would make every call site read like a discriminated parse of its own argument.

How this works without owning a player

From the broadcasts the app already sends. The deadline is (duration - projectedPosition) / rate — both halves are already on the playbackState and mediaItem channels — computed natively and re-armed on every broadcast, so a seek, a pause, a rate change or a late-arriving duration all move it. Nothing polls and nothing new crosses the bridge.

Two cases handled without a duration at all:

  • the current item changes (the track ended and the app advanced, or the user skipped) → it fires immediately, which is the honest reading of "stop after this one";
  • no duration was ever broadcast (a live stream, or it has not arrived yet) → armed with no deadline, waiting for that item change. getSleepTimer reports trackEnd with no remainingSeconds rather than inventing one.

When it fires, everything is identical to setSleepTimer: playback is paused natively first, then MediaHandler.onSleepTimer.

Returns

void

Inherited from

MediaServiceApi.setSleepTimerToTrackEnd


stopService()

stopService(): Promise<void>;

Defined in: packages/media-session/src/types.ts:950

End background execution. The ONLY thing that does — pause() never does (PLAN §5.4). After this resolves, init may be called again.

Returns

Promise<void>

Inherited from

MediaServiceApi.stopService