Xeonr Developer Docs

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:

FieldWhat it is
receiver_uuidThe handle senders address. Minted fresh on every attach — never persist one across sessions.
heartbeat_interval_msHow often to call ReportReceiverState. Server-owned, so it can be retuned without a client release.
heartbeat_grace_beatsHow 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

DirectiveWhat to do
loadStop 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 / pauseResume or hold.
seek_toGo to position_ms.
set_tracksChange audio and/or subtitle track. clear_subtitle_track turns subtitles off, since an absent optional cannot say "none".
set_qualityPin a rung from the ladder.
set_rateOnly if you declared rate_supported.
set_volumeOnly if you declared volume_supported. tvOS cannot — the television's own remote owns volume there.
stopClose 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

SituationWhat you will see
The sender's app is closed or asleepNothing changes. The receiver owns the session and the beats. Re-open WatchReceiver on return.
The receiver's stream dropsDirectives keep arriving in heartbeat responses. Reconnect with backoff; nothing is lost.
The receiver stops beatingIt 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 reconnectsIt 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 receiverFailedPrecondition. A television that is switched off says so now, rather than acting on it whenever it is next turned on.
The account signs outStreams 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.

On this page