Remote playback
Remote playback lets one of your devices drive another: you pick something on
your phone, and the television plays it. One service carries it,
RemotePlaybackService under xmb.api.v1, and it has two sides — a
receiver (the device that decodes and displays) and a sender (the
device holding the remote).
PlaybackService is untouched by any of this. A remote-controlled play is an
ordinary playback session; it just happens to have been started by a device
somebody was not holding.
The one rule everything follows from
The receiver opens its own playback session. A sender never calls
StartPlayback, never holds a media token, and never beats RenewPlayback. It
says which episode; the receiver does the rest.
If you are writing a sender, that is the whole of your job: list receivers, send
directives, render what comes back. If you are writing a receiver, you run the
entire ordinary playback flow — StartPlayback with your own measured
ClientCapabilities, the thirty-second RenewPlayback beat, StopPlayback on
the way out — and additionally report what you are doing.
Three things force this arrangement, and any one of them would be enough:
- Capabilities are measured on the decoder, never declared for it. A phone passing on a television's capability profile is passing on a copy, and the copy goes stale the first time the television takes an OS update. Playback negotiation exists precisely to avoid guessing what a device can decode.
- A media token belongs on the device fetching the bytes. Tokens live for minutes. Relaying one adds a hop, through a device that may be in a pocket.
- The renewal beat carries the playhead. Only the thing that is playing knows where it is — and a phone that falls asleep mid-film must not be able to end one.
Being a receiver
Call AttachReceiver with your ClientInfo, your measured
ClientCapabilities, and a ReceiverFeatures saying whether volume and
playback rate can be commanded on your platform. It is a server-streaming
method; hold it open for as long as your app is running.
The first frame is always a ReceiverWelcome:
| Field | What it is |
|---|---|
receiver_uuid | The handle senders address. Minted fresh on every attach — never persist one across sessions. |
heartbeat_interval_ms | How often to call ReportReceiverState. Server-owned, so it can be retuned without a client release. |
heartbeat_grace_beats | How many you may miss before you are detached. |
After that the stream carries Directive frames and periodic ReceiverPing
keepalives. A ping means nothing except that the connection is alive — a stream
that has gone half-open is otherwise indistinguishable from a quiet one.
Then beat. Every heartbeat_interval_ms, call ReportReceiverState with a
ReceiverState describing what you are doing and the highest directive id you
have applied. Also report immediately whenever something discrete changes —
play, pause, a track selection, a failure — rather than waiting for the next
tick; the clock alone can wait for the beat.
Directives may arrive twice
Every directive is durable on the platform before it is pushed to you. You will receive it on the stream if your stream is healthy, and in the response to your next heartbeat if it was not. Both paths carry the same directive with the same id, so every command you implement must be safe to apply twice.
Directive.id ascends per receiver. Report the highest one you have applied as
ReceiverState.acked_directive_id; ReportReceiverStateResponse.pending
returns everything above it, oldest first. Apply them in order — a seek applied
before the load it belongs to is a seek into the wrong film.
The practical consequence: you do not need the stream to be correct. If
server-streaming is awkward on your platform, a receiver that only ever polls
ReportReceiverState works, at the cost of up to one heartbeat of latency per
button press.
The commands
| Directive | What to do |
|---|---|
load | Stop whatever is playing and start this episode. Carries the episode, optional cut, start position, language preferences, optional track and rung pins, and display strings. |
play / pause | Resume or hold. |
seek_to | Go to position_ms. |
set_tracks | Change audio and/or subtitle track. clear_subtitle_track turns subtitles off, since an absent optional cannot say "none". |
set_quality | Pin a rung from the ladder. |
set_rate | Only if you declared rate_supported. |
set_volume | Only if you declared volume_supported. tvOS cannot — the television's own remote owns volume there. |
stop | Close the session and go back to idle. |
load carries display_title, display_subtitle and display_artwork_url so
you can paint a loading screen immediately rather than showing a spinner for the
length of a metadata fetch. They are display only — what actually gets
played is decided by episode_uuid alone.
Never refuse a load because something is already playing; replace it. A sender
that wants to ask "are you sure?" asks before it sends.
If you cannot honour a track or rung pin, fall back to your negotiated default rather than failing the load. Starting with the wrong audio track costs one more directive to fix; a black screen does not.
Being a sender
ListReceivers returns your own attached devices, with their label, their
capability profile, their features and their last reported state. The
capabilities are there so you can grey out something a television would refuse
before somebody presses the button — do not forward them anywhere.
SendDirective queues one instruction and returns the directive_id it was
given. Hold that id: when a reported state's acked_directive_id reaches it,
that press has landed, which is what lets an optimistic UI reconcile rather than
fight the user's thumb.
WatchReceiver streams the receiver's state as it changes; GetReceiver is the
same answer once. Every sender must be able to fall back to GetReceiver —
a remote that polls feels laggy, but a remote that shows nothing is broken.
ReceiverState carries the available audio_tracks, subtitle_tracks and
ladder alongside the current selections, so you can draw a track menu without
running a negotiation you are not party to — and you could not run a truthful
one anyway, since the answer depends on the receiver's decoder rather than
yours.
SubtitleTrack.url is always absent in a ReceiverState. Those URLs carry a
media token minted for the receiver's session, and the platform strips them
before this message is stored or handed to anyone. Use the track list to draw a
menu, not to fetch anything.
Positions are film time
Every position on this surface is film time, exactly as on RenewPlayback. A
receiver playing an HLS transcode must add MediaDelivery.playlist_start_ms
before a position leaves it and subtract it before one reaches its player. Get
this wrong and a resumed transcode reports minute zero, which reads as a seek,
retargets the encoder, and loops.
Who may command what
A sender may command a receiver when it is the same account, on the same server. Not because commanding a housemate's television is unthinkable, but because the receiver writes its watch progress, its history and its session rows as the account it signed in with — a directive from somebody else would put their viewing on another person's account, silently.
A receiver that belongs to somebody else answers NotFound, not
PermissionDenied: its existence is not yours to learn.
install_id authorizes nothing. It is client-minted and unauthenticated; it
exists so two identical televisions in one house are two rows an owner can tell
apart and label.
Two senders driving one receiver is allowed and uncoordinated. Directives serialize by id and the last one wins, which is what two people holding two remotes already expect.
What happens when things break
| Situation | What you will see |
|---|---|
| The sender's app is closed or asleep | Nothing changes. The receiver owns the session and the beats. Re-open WatchReceiver on return. |
| The receiver's stream drops | Directives keep arriving in heartbeat responses. Reconnect with backoff; nothing is lost. |
| The receiver stops beating | It is detached after heartbeat_grace_beats, disappears from ListReceivers, and watchers are told. Its playback session is closed separately by the ordinary idle reaper. |
| The receiver reconnects | It attaches with a new receiver_uuid; the previous attachment for that install is superseded so it is one row in the list, not two. |
| A directive is sent to a detached receiver | FailedPrecondition. A television that is switched off says so now, rather than acting on it whenever it is next turned on. |
| The account signs out | Streams are dropped and the receiver stops being addressable. |
Uncollected directives are discarded after a few minutes. An instruction nobody collected in that time is one whose moment has passed — a film that pauses itself ten minutes late is worse than one that never paused.
Chromecast
MintReceiverCredential and RefreshReceiverCredential are declared on the
service and return Unimplemented today. They exist for a receiver that
cannot sign in for itself — a Cast web receiver launched by a protocol that has
no idea who anybody is — which will be handed a short-lived, narrowly-scoped
credential by the sender that launched it and then run as an ordinary receiver.
The shape is on the wire now so clients written against it need no release on
the day it lands.