Interface: MediaServiceController
Defined in: packages/media-session/src/media-service.ts:44
MediaServiceApi plus the one-time wiring call.
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
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
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
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
init()
init(handlerFactory: () => MediaHandler, config?: MediaServiceConfig): Promise<MediaServiceApi>;
Defined in: packages/media-session/src/media-service.ts:55
Wire handlerFactory() to every remote surface and configure the platform.
The handler is created by the service (rather than passed in) so an app
can build it against resources that only exist once the session does —
audio_service's AudioServiceInitializer shape.
Parameters
| Parameter | Type |
|---|---|
handlerFactory | () => MediaHandler |
config? | MediaServiceConfig |
Returns
Promise<MediaServiceApi>
Throws
alreadyInitialized if called twice without an
intervening MediaServiceApi.stopService.
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
| Parameter | Type |
|---|---|
parentId? | string |
Returns
void
Inherited from
MediaServiceApi.invalidateBrowse
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
| Parameter | Type |
|---|---|
item? | NativeMediaItem |
Returns
void
Inherited from
setPlaybackState()
setPlaybackState(state: PlaybackState): void;
Defined in: packages/media-session/src/types.ts:840
Broadcast channel 1 of 3.
Parameters
| Parameter | Type |
|---|---|
state | PlaybackState |
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
| Parameter | Type |
|---|---|
items | NativeMediaItem[] |
Returns
void
Inherited from
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
| Parameter | Type |
|---|---|
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
| Parameter | Type |
|---|---|
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
| Parameter | Type | Description |
|---|---|---|
seconds | number | strictly positive and finite. |
Returns
void
Throws
invalidArgument for 0, negatives, NaN,
Infinity or a non-number.
Inherited from
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
trackEndwith noremainingSecondsrather 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
subscribeCarConnection()
subscribeCarConnection(listener: () => void): () => void;
Defined in: packages/media-session/src/media-service.ts:69
Call listener whenever MediaServiceApi.getCarConnection would
start returning something different. Returns the unsubscribe.
On the controller rather than in MediaServiceApi because it is not
a session command: it is the plumbing behind the useCarConnection() hook,
and it deliberately outlives init/stopService so a component mounted
before the session exists still hears the first connection.
Parameters
| Parameter | Type |
|---|---|
listener | () => void |
Returns
() => void