Control remoto
Con la sesión abierta, tu app puede manejar lo que se reproduce en el TV y ver qué está pasando: posición, duración, pausa, volumen, subtítulos y audio.
Enviar un comando
POST /v1/sessions/commands con el sessionToken. El TV lo aplica al instante y lo muestra en pantalla (destello de pausa, "+30 segundos", barra de volumen), igual que si se usara el control remoto.
type | Campos | Qué hace |
|---|---|---|
play | — | Reproduce. Si el video terminó, empieza de nuevo. |
pause | — | Pausa. |
stop | — | Cierra el reproductor; el TV vuelve a "Teléfono conectado". |
seek | position (segundos) | Va a ese punto. |
skip | seconds | Adelanta, o retrocede si es negativo. |
volume | level (0–100) | Volumen del reproductor. Si estaba en silencio, lo quita. |
mute | muted (true/false) | Silencia o quita el silencio. |
subtitles | trackId o null | Activa esos subtítulos; null los desactiva. |
audio | trackId | Cambia el idioma del audio. |
Todos aceptan además castId: si el TV ya está reproduciendo otro video (alguien envió uno nuevo), el comando se rechaza con 409 cast_mismatch en vez de pausar el video equivocado.
sh
curl -X POST https://beta.playeroff.app/v1/sessions/commands \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"type": "skip", "seconds": 30}'js
await fetch('https://beta.playeroff.app/v1/sessions/commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sessionToken}` },
body: JSON.stringify({ type: 'volume', level: 40 }),
})dart
await http.post(
Uri.parse('https://beta.playeroff.app/v1/sessions/commands'),
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $sessionToken'},
body: jsonEncode({'type': 'subtitles', 'trackId': '54'}),
);Respuesta 202 { "commandId": "…" }. Cuando el TV lo aplica, el siguiente estado de reproducción trae ese commandId en lastCommandId.
409 nothing_playing: no hay video en el TV, se detuvo o falló. Envía uno conPOST /v1/casts.- Un comando que no llega al TV en 10 segundos se descarta: una pausa aplicada medio minuto tarde es peor que nada.
- Volumen: es el del reproductor, entre 0 y 100 % del volumen del TV. El volumen del TV en sí se sigue manejando con su control.
Ver qué se está reproduciendo
GET /v1/sessions/playback devuelve { "playback": … }, o null si el TV todavía no reprodujo nada:
json
{
"castId": "WFSRb-51hgKoO2YGXWByaQ",
"title": "Mi película",
"state": "playing",
"position": 754.2,
"duration": 6120,
"volume": 80,
"muted": false,
"subtitles": [{ "id": "54", "lang": "es", "label": "Español" }],
"activeSubtitleId": null,
"audioTracks": [{ "id": "en", "lang": "en", "label": "Inglés" }, { "id": "es", "lang": "es", "label": "Español" }],
"activeAudioId": "en",
"lastCommandId": "…",
"updatedAt": 1800000123000
}state:loading,playing,paused,buffering,ended,stoppedofailed(conerror).positionydurationen segundos;durationesnullmientras se desconoce o en vivo.- Los
idde las pistas son los que tienes que mandar en los comandossubtitlesyaudio. Úsalos tal cual, sin interpretarlos. - Mientras
stateseaplaying, avanzapositionpor tu cuenta (el TV informa cada 5 segundos y en cada cambio).
El estado es del TV, no de tu sesión: si te conectas mientras algo se reproduce, lo ves y lo puedes controlar.
Seguir el TV en vivo (SSE)
GET /v1/sessions/events con Authorization: Bearer <sessionToken> es un flujo de Server-Sent Events:
| Evento | Datos | Cuándo |
|---|---|---|
playback | Playback o null | Al conectar y en cada cambio en el TV |
cast | { castId, status, error? } | Cuando cambia el estado de uno de tus envíos |
ended | { reason } | La sesión terminó; el servidor cierra el flujo |
Se permiten 2 flujos abiertos a la vez por sesión en cada servidor. Al abrir otro se cierra el más antiguo, y recibe ended con reason: superseded. También se cierra un flujo cuyo cliente deja de leer.
reason es replaced (otra persona se conectó), disconnected (alguien desconectó o el TV se cerró), expired o superseded. Salvo en superseded, que no debe reabrirse, para seguir hay que canjear un código nuevo.
Un video que lleva 30 s sin informar llega como playback con state: stopped.
EventSource del navegador no permite enviar cabeceras; desde una web usa fetch y lee el cuerpo como stream. En Flutter, http.Client().send() con la cabecera y response.stream funciona igual.
js
const res = await fetch('https://beta.playeroff.app/v1/sessions/events', {
headers: { Authorization: `Bearer ${sessionToken}` },
})
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
let i
while ((i = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, i)
buffer = buffer.slice(i + 2)
const event = block.match(/^event: (.*)$/m)?.[1]
const data = block.match(/^data: (.*)$/m)?.[1]
if (event) console.log(event, JSON.parse(data))
}
}dart
final req = http.Request('GET', Uri.parse('https://beta.playeroff.app/v1/sessions/events'))
..headers['Authorization'] = 'Bearer $sessionToken';
final res = await http.Client().send(req);
String? event;
await for (final line in res.stream.transform(utf8.decoder).transform(const LineSplitter())) {
if (line.startsWith('event: ')) event = line.substring(7);
if (line.startsWith('data: ') && event != null) print('$event ${line.substring(6)}');
if (line.isEmpty) event = null;
}Si el flujo se corta, vuelve a abrirlo: el primer evento trae el estado actual. Si no consigues mantenerlo abierto (algunas redes cortan conexiones largas), consulta GET /v1/sessions/playback cada pocos segundos.
