Vista generada de dec-0128: Dispositivos, presencia, mando remoto, handoff y capa de cast
docs/decisions/dec-0128-dispositivos-presencia-mando-remoto-handoff-cast.mdVista generada desde
docs/decisions/dec-0128-dispositivos-presencia-mando-remoto-handoff-cast.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | PROPOSED |
| Fecha | 2026-10-01 |
| Fichero | docs/decisions/dec-0128-dispositivos-presencia-mando-remoto-handoff-cast.md |
Por qué importa (del frontmatter del ADR):
Fija cómo se ven y se controlan entre sí los dispositivos de Styx: registro de dispositivos por cuenta y hogar (identity-svc), presencia y "reproduciendo ahora" con metadatos (playback-svc como coordinador de sesiones, estado efímero en Valkey), comandos de mando remoto con ack, idempotencia y generación, y handoff make-before-break con posición exacta que transfiere la LogicalGenerationId (dec-0090). Todo va por el plano propio: realtime-svc como pasarela bidireccional, bus spire (BUS_ROUTES) y SCT, sin protocolos de cast. La capa de cast (Google Cast, AirPlay, UPnP/DLNA, DIAL, Matter Casting) queda desacoplada detrás de un registro de destinos externos, delivery-edge como Delivery Gateway y un rol lan-bridge para el descubrimiento LAN, y propone sacar el cast externo del DEFERRED de r60 D7. Sin este ADR cada cliente (web, Swift, Pi 3, TV) inventaría su propio protocolo de mando, la presencia acabaría guardada en realtime-svc (prohibido por su lock F0.A.3) y el cast entraría como atajo que salta la autorización y el SCT.
Nodos del roadmap que lo citan en refs: ninguno.
Páginas de la documentación que lo citan: Dispositivos, mando y handoff (especificado)
Leído de docs/decisions/dec-0128-dispositivos-presencia-mando-remoto-handoff-cast.md, el fichero canónico.
AskUserQuestion, con las
preguntas de §15 resueltas o diferidas de forma explícita. Mientras no se lockee no se
implementa nada: describe el sistema objetivo para que la doc (dec-0124 §6) lo especifique y
los contratos se diseñen antes que el código.AskUserQuestion; no lockea el ADR): P2
resuelta. La base A0 (registro de dispositivos, presencia y foto «reproduciendo ahora»,
con sus contratos) entra justo después del MVP (outcome/first-vertical), no dentro de él.
El resto de §15 sigue abierto y el ADR sigue PROPOSED (§14.3, §15).w10/adr-dispositivos, desde w10/vision). Número
reservado para este workflow. Nodo track/devices y sus milestones: alta propuesta queued en el model, pendiente de ratificar en
P1 (§15).docs/overview/vision-ledger.yml):
V-N05, V-DEV-02: handoff y mando remoto entre dispositivos sincronizados, sobre el plano
propio (realtime/MoQT/bus), sin protocolos de cast.V-DEV-01: watch party, de momento no.V-DEV-03: después, cast first-class desde la app y la web (AirPlay, Chromecast, DIAL…), con
descubrimiento LAN y de los dispositivos conectados al mismo servidor desde otras LAN.V-DEV-04: la capa se deja pensada para la vertical o justo después.V-DEV-05: la app Swift ya tiene parte del remote play.V-DEV-11: dec-0090, el origen transfiere la generación.V-DEV-12: quick connect de TV (dec-0125 §10), de donde salen los dispositivos.V-N02 (players vendorizados primero, §11.2) y V-N10 (offline, fuera: §13).r01: los bytes de vídeo no atraviesan JS ni el bus. Los comandos y la presencia son
control plane; ningún byte de media pasa por realtime-svc.r16: frontier salvo blocker (make-before-break, Matter Casting como candidato, receptor Cast
propio).r17: un contenedor por authority. No nace ningún servicio: identity, playback y realtime
reparten el trabajo (§3), y delivery-edge (lockeado en r17, sin casa) es el Delivery Gateway
del cast (§11.3).r20 §3.2: realtime-svc es superficie pública filtrada, nunca evt.> crudo.r54 D2.3 + dec-0069: el Client Session Controller (CSC) es el único creador de
LogicalGenerationId.dec-0070 / dec-0090 (LOCKED): en un handoff el origen transfiere la generación activa
y el receptor la hereda; ADD_CONTROLLER y MOVE_TO_GROUP son sólo observador/entrada.
TAKE_OVER y RETURN_TO_ORIGIN son variantes de HANDOFF.dec-0094 (LOCKED): SessionStyxCapsule es la primitiva que viaja en el handoff.dec-0111 D8 (PROPOSED): el handoff transporta también last_issued.r60 D7 (LOCKED): el cast externo está DEFERRED "hasta demanda real + threat model
separado"; MIRROR fuera. Este ADR aporta las dos cosas y propone la enmienda (§14.2).r19 / dec-0007: DLNA/Cast/SharePlay eran features del cliente Swift, no del runtime. Este
ADR lo respeta: los adaptadores de protocolo viven en clientes y en el bridge. El registro, la
autorización y la entrega son del runtime.dec-0117 §4.1: SCT v1 con scopes read/publish/ingest/control. control existe en el
formato y hoy el daemon lo rechaza (test SEC-Z01); este ADR le da uso fuera del daemon (§9.3).dec-0118 (LOCKED): BFF, roles, Authorized<R, A>, audit, rotación al cambiar privilegios.dec-0119 / dec-0120 (LOCKED): todo por spire, subjects en BUS_ROUTES, ACL generada.dec-0124 (PROPOSED): la página especificado atada a este ADR (§16) y el registro de
operaciones (§12.3).dec-0125 (PROPOSED): cuenta, hogar, perfiles, device grant, pid en el JWT, can() por
intersección. Este ADR añade el dispositivo como entidad y los permisos de mando.dec-0127 (reservado, modelo de datos): de dónde sale la metadata y el artwork del "reproduciendo
ahora" (§6.2).Verificado sobre el árbol de w10/vision (base w9/docs sobre 922dedc).
| Pieza | Estado | Dónde |
|---|---|---|
realtime-svc: WS /ws detrás de la policy authenticated, Origin en allowlist, topes global y por actor, cierre a la caducidad del principal | existe | apps/realtime-svc/src/service/ws/admission.ts (TS-P2-02) |
realtime-svc proyecta evt.media.transportSignals a 5 claves públicas. Consumer-only, sin presence, sin replay, registry de clientes sólo en memoria | existe (F0.A.3) | apps/realtime-svc/README.md §Caveat y §Prohibido |
Sesiones de reproducción con propietario (actor), SCT read de 600 s, cierre a los 630 s, tope por actor | existe | apps/playback-svc/src/core/handlers/PlaybackHandler.ts (TS-P1-01, sec#21) |
CapabilityScope = 'read' | 'publish' | 'ingest' | 'control'; el daemon rechaza ingest y control en MoQT | existe | apps/playback-svc/src/core/ports/index.ts:221, native/zig/media-daemon/moqt/moqt_handler_test.zig:527 |
| Semántica de handoff (el origen transfiere la generación) | lock, sin código | dec-0070, dec-0090, r54 D2.3 |
SessionStyxCapsule | lock, sin código | dec-0094, track/offline-continuity (queued) |
| Remote Playback Fabric (modos, roles, Delivery Gateway, preferencia de protocolos) | candidato | docs/design/styx-next-wave-architecture-candidates/02-remote-playback-session-mobility-and-bridge.md |
Sesiones browser/native/device con perfil, deviceName, profileLocked; device.list/.revoke | PROPOSED | dec-0125 §4.3, §10, §11.1 |
| App Swift: DLNA/Cast "RemotePlay" funcionando, AVPlayer con AirPlay | fuera del repo | r19 (inventario de MKS-IPTV-App), apps/apple/README.md (TARGET post F-FUSION, sin código) |
| Origen TCP para receptores que no hablan HTTP/3 | falta | el daemon sólo habla HTTP/3 (git:w8/vertical-plan:docs/track/byte-runtime/plans/vertical-vod-web.md §1.1) |
| Registro de dispositivos, presencia, "reproduciendo ahora", mando remoto, handoff, cast | nuevo | este ADR |
Tres restricciones del estado actual que este ADR trata de frente:
| Referencia | Qué hace | Qué se toma / qué no |
|---|---|---|
| Spotify Connect | El emparejamiento y la transferencia pasan por la nube, no de dispositivo a dispositivo. Cualquier dispositivo con la sesión de la cuenta es un mando. API "Transfer Playback" con la lista de dispositivos. Zeroconf sólo para dar de alta los altavoces. | Es el modelo base: el servidor es el punto de encuentro (§4), funciona entre LAN distintas sin descubrimiento, y un dispositivo es mando y destino a la vez. No se toma el "un único dispositivo activo por cuenta". |
| YouTube "Ver en la TV" (pairing con código, DIAL para lanzar la app) | Enlaza el móvil con la TV con un código, la cola vive en el servidor, el móvil hace de mando. El handoff es aproximado: posición redondeada, cortes, pérdida de pistas. | El código de TV para mando invitado (§9.3) y DIAL como APP_LAUNCH (§11). Lo que waxin quiere superar: posición exacta, pistas y cola conservadas y sin hueco (§8). |
| Jellyfin Sessions API | GET /Sessions con "now playing", POST /Sessions/{id}/Playing (PlayNow/PlayNext/PlayLast), comandos generales (SetVolume, SetAudioStreamIndex, SetSubtitleStreamIndex, navegación D-pad, DisplayMessage), SupportedCommands por sesión. | El vocabulario y que cada destino declare los comandos que soporta (§7.1). No se toma lo que falla: el comando contesta 204 sin saber si se aplicó (hay hilos de 10.11 con comandos que "no hacen nada"); aquí hay ack. |
| Plex Companion / GDM | Reproductores anunciados por la LAN (GDM, multicast UDP) o por plex.tv; el mando habla con el reproductor directamente o a través del servidor. | La doble vía LAN/servidor se descarta para v1: todo pasa por el servidor (menos superficie, una sola autorización). Descubrimiento LAN sólo para destinos sin cliente Styx (§11). |
W3C Remote Playback API (HTMLMediaElement.remote) | El navegador ofrece sus destinos (Cast en Chrome Android, AirPlay en Safari) y la página controla la reproducción remota con el propio <video>. Soporte fragmentado; no es Baseline. En Safari, AirPlay además por webkitShowPlaybackTargetPicker. | Es el camino web para cast sin SDK (§11.2). Exige una fuente por URL: con MSE/ManagedMediaSource el cast no funciona (rx-player documenta disableRemotePlayback obligatorio con MMS, BDQ-05 del harvest r7). |
| Google Cast Web Sender (CAF) + Web Receiver | Sender en navegadores con Cast (Chrome de escritorio y Android; no Chrome de iOS), HTTPS obligatorio, applicationId registrado. Receptor por defecto o receptor web propio alojado en HTTPS. | Un receptor web propio de Styx que, al cargar en el Chromecast, se conecta a realtime como un dispositivo Styx más: el Chromecast pasa de "destino tonto" a destino con presencia, mando y handoff (§11.4). |
AirPlay (AVFoundation, AVRoutePickerView, allowsExternalPlayback) | Sólo desde sistemas de Apple o Safari. El protocolo no está abierto. | AirPlay sólo desde la app Swift y Safari. Nunca un emisor AirPlay propio en el bridge (protocolo cerrado, licencia). |
UPnP AV / DLNA (SSDP, AVTransport.SetAVTransportURI, DIDL-Lite, GetPositionInfo) | Un control point descubre renderers por SSDP y les pasa una URL; el renderer descarga por HTTP/1.1. | Control point en el lan-bridge (§11.5). DIDL-Lite lleva título y portada: la TV DLNA también muestra los metadatos. GetPositionInfo alimenta la presencia. |
| DIAL | Descubrimiento por SSDP y lanzamiento de una app registrada en la TV. | Sólo APP_LAUNCH: lanzar la app de Styx en la TV si está instalada, y luego handoff nativo. |
| Matter Casting (Matter 1.3, Fire TV y Echo Show; cliente en el móvil, "Casting Video Player" en la TV) | Descubrimiento, lanzamiento de app y controles de reproducción (play, pausa, seek, volumen) estandarizados. | Candidato frontier (r16) para una fase posterior, con spike propio. Lo implementan las apps nativas, no la web. |
Apple MediaPlayer (MPNowPlayingInfoCenter, MPRemoteCommandCenter) | Pantalla de bloqueo, Centro de control, mando del Apple TV y del Watch. | La app Swift publica ahí el estado de la sesión Styx, local o remota: el móvil controla la TV desde la pantalla de bloqueo. |
device con id estable, ligado a una
cuenta o a un hogar (dispositivo compartido), con nombre, tipo, plataforma y capacidades.
Las sesiones de dec-0125 cuelgan de un dispositivo (§5).cmd.*; lo que sale lo filtra por audiencia (§4, enmienda §14.2).LogicalGenerationId (dec-0090). Si el destino no
llega a estar listo, el origen no se entera: no hay hueco (§8).dec-0125: mandar sobre un dispositivo exige poder verlo;
lo que se carga en él cumple a la vez el perfil del destino y el del mando. El mando invitado
sin cuenta usa una SCT de scope control (§9).externalTarget, descubiertos por quien puede (el navegador, la app nativa o un lan-bridge
en la LAN), y reciben la media por delivery-edge como Delivery Gateway. Preferencia: Styx
nativo > receptor Cast de Styx > Cast/AirPlay oficiales > DLNA > DIAL (sólo lanzar). Mirroring
fuera (§11). móvil (web/app) TV (app/web/Pi 3) receptor Cast de Styx
│ WS (hoy) / WT (con delivery-edge) │ │
└──────────────┬─────────────────────┴─────────────────────┘
▼
realtime-svc ── pasarela: autentica, limita, traduce, filtra por audiencia
│ ▲ (registro de sockets en memoria; sin presencia guardada)
cmd.playback.*│ │evt.playback.* (proyectado)
▼ │
playback-svc ── coordinador: sesiones, presencia, "reproduciendo ahora",
│ │ autorización de mando/handoff, plan para el destino (r05),
│ │ SCT para el destino, Valkey (estado efímero con TTL)
qry.identity.*│ │qry.catalog.*
▼ ▼
identity-svc catalog-svc
(dispositivos, (metadata y artwork del
permisos) "reproduciendo ahora", dec-0127)
bytes: cliente ⇄ daemon (WT/MoQT/H3) con SCT; receptores externos ⇄ delivery-edge (§11.3)| Cosa | Autoridad | Por qué |
|---|---|---|
| Existencia, dueño, nombre y revocación del dispositivo | identity-svc | Es credencial y acceso (r17, dec-0087, dec-0125 §10). Revocar un dispositivo corta sus sesiones y su socket. |
| Qué se reproduce, dónde, para qué cuenta y perfil | playback-svc | Ya es dueño de las sesiones de reproducción y del SCT. El coordinador no es actor nuevo (mismo criterio que r60 D5). |
| Estado real de la presentación (posición, pausa, pistas) | el CSC del dispositivo que reproduce | r54 D2.3: único creador de generaciones. playback-svc guarda la última foto que el dispositivo informa, no la inventa. |
| Canal con el dispositivo, topes de conexión, Origin | realtime-svc | Ya lo hace (TS-P2-02). Sigue sin estado de dominio. |
| Metadata y artwork del "reproduciendo ahora" | catalog-svc (índice derivado) | Lo fija dec-0127. Aquí sólo se exige que la foto lleve refs estables y un hash de la metadata (§6.2). |
| Bytes para receptores externos | delivery-edge (+ daemon) | Candidato 02 §Delivery Gateway; r17 ya reserva el servicio. |
/ws de realtime-svc. Mensajes JSON pequeños, validados con TypeBox
(@styx/api-contracts, dec-0116), con versión de esquema en cada mensaje.delivery-edge, sin cambiar los
mensajes (el contrato es el mismo con otro transporte).V-DEV-01,
fuera por ahora). Si llega, será una pista MoQT de reloj, no el canal de mando.Bytes de media, URLs de almacenamiento, tokens de la cuenta, IPs de otros dispositivos. La única
información de red que se expone es un booleano sameNetwork calculado en el servidor con la
política de dirección de cliente de TS2-P2-02 (prefijo /64 en IPv6).
| Campo | Valor |
|---|---|
id | uuid estable por instalación. Lo genera identity al aprobar el device grant o al primer login nativo/web |
owner | { kind: 'actor', id } o { kind: 'household', id } (dispositivo compartido del hogar, §9.2) |
name | editable ("TV del salón", "Chrome en el Mac") |
kind | tv | phone | tablet | desktop | browser | appliance | cast-receiver | cli |
platform | web | ios | ipados | macos | tvos | android | androidtv | pi3 | chromecast | other |
roles | subconjunto de controller (puede mandar), target (puede reproducir), bridge (lan-bridge, §11.5) |
capabilitiesRef | ClientCapabilities versionadas (r20) + remote: { commands[], maxRate? } que el destino declara (§7.1) |
defaultProfileId, profileLocked | de dec-0125 §6 y §10.4 |
createdAt, lastSeenAt | lastSeenAt se actualiza como mucho cada pocos minutos (no por heartbeat) |
identity.devices; las sesiones de dec-0125 §4.3 ganan device_id. El cli existe
como dispositivo (para listarlo y revocarlo) pero nunca es target.localStorage) y se distinguen por tabId efímero.device.rename, device.revoke (corta sesiones y socket; evt.security.deviceRevoked),
device.share (pasar a dispositivo del hogar) y device.unshare.evt.realtime.deviceConnected / deviceDisconnected (id de dispositivo, de
socket, instante). playback-svc mantiene playback:presence:<deviceId> en Valkey con TTL; el
canal manda un latido barato y el TTL lo limpia si el proceso muere sin desconectar. Los valores
de TTL y latido son reversibles y se fijan en la implementación.online (canal abierto), playing/paused/buffering/idle (si es target),
offline (sin canal; aparece con lastSeenAt si se pide).sameNetwork sólo pinta la insignia "en
tu red / en otra red".El destino informa su estado (cmd.playback.reportState, por eventos y como mucho una vez por
segundo mientras reproduce). playback-svc lo valida contra la sesión que conoce y publica la foto:
NowPlaying {
schemaVersion: 1
deviceId, sessionId, actorId, profileId
media: { workId, editionId?, assetId, episode?: { season, number }, kind: 'vod' | 'live' }
metaRef: { catalogVersion, metadataHash } // dec-0127: el cliente cachea por hash
display: { title, subtitle?, artworkRef?, year? } // ya resuelto y filtrado por perfil
live?: { channelId, programme?: { title, start, end } /* EPG */, streamTitle? /* ICY */ }
state: 'playing' | 'paused' | 'buffering' | 'ended' | 'error'
position: { ticks, timescale } // posición de presentación exacta, no segundos en coma flotante
duration?: { ticks, timescale }
rate, volume?, muted?
tracks: { audio: TrackRef, subtitle: TrackRef | null, subtitleOffsetMs? }
queue?: { contextRef, index, length } // "siguiente episodio", lista
generation // LogicalGenerationId actual
representation: { delivery, video?, audio? } // del PlaybackPlan, para el "por qué" (r05)
observedAt // reloj del servidor
}TrackRef identifica la pista por idioma, tipo y huella de pista, no por índice: así el
handoff a un destino que empaqueta distinto conserva la elección.track/epg); en
radio o streams con metadatos ICY, el StreamTitle actual. Es la parte de "toda la metadata"
que waxin pidió para la TV.display lo resuelve playback-svc con qry.catalog.* filtrado por el perfil de quien mira:
si un perfil infantil ve que el móvil de un adulto reproduce algo por encima de su madurez, ve
"Contenido restringido", nunca el título (§9.1).observedAt y rate entre fotos: la barra se mueve suave
sin pedir una foto por frame.| Familia | Comandos | Notas |
|---|---|---|
| Transporte | play, pause, togglePause, stop, seekTo{ticks,timescale}, seekBy{ms}, setRate | seekBy relativo se resuelve en el destino; la idempotencia evita aplicarlo dos veces |
| Pistas | setAudioTrack{TrackRef}, setSubtitleTrack{TrackRef | null}, setSubtitleOffset{ms} | |
| Volumen | setVolume{0..1}, mute, unmute | Del player, no del sistema. Volumen de sistema sólo si el destino lo declara (TV con CEC, P8) |
| Contenido | load{assetRef, startAt?, tracks?, queue?}, queueAdd, next, previous | "Cambiar lo que se está viendo". Pasa por la autorización de §9 antes de llegar al destino |
| Saltos | skipSegment{kind: 'intro' | 'credits' | 'recap'} | Depende de V-N04 (intro/créditos, sin nodo): el comando existe sólo si el destino lo declara |
| Navegación | nav{up,down,left,right,select,back,home}, showMessage{text} | Opcional, para TV y Pi 3 (como el D-pad de Jellyfin). Fuera de v1 salvo que waxin lo pida (P8) |
Cada destino declara en capabilities.remote.commands qué soporta. El mando sólo pinta lo
soportado y playback-svc rechaza el resto con REMOTE_COMMAND_UNSUPPORTED antes de enviarlo.
mando ──cmd.playback.remoteCommand{commandId, targetDeviceId, sessionId, seq, expectGeneration?, command}──►
playback-svc: can() (§9) · existe la sesión · destino online · comando soportado · tope de ritmo
──evt.playback.remoteCommand──► realtime ──► destino (sólo su socket)
destino: CSC aplica como ENTRADA (dec-0070: el mando es observador/entrada); si cambia la
posición o el contenido, el CSC crea la generación nueva
──cmd.playback.remoteAck{commandId, result: applied|rejected|superseded, state}──►
playback-svc: actualiza la foto · ──evt.playback.remoteAck + evt.playback.nowPlaying──► mandoscommandId: un reintento del mando no aplica dos veces un seekBy.seq por par (mando, sesión): un comando más viejo que el último aplicado de ese
mando se descarta como superseded.seekTo lanzado sobre la foto de la generación N se
rechaza si el destino ya está en otro contenido (stale-generation), en vez de saltar dentro de
un vídeo que el usuario no está mirando.load: cambiar el contenidoload no lleva una URL: lleva una referencia de catálogo. playback-svc hace el plan (r05) con
las capacidades del destino, abre la sesión de reproducción para la cuenta y el perfil que toca
(§9), emite la SCT del destino y se lo manda. El destino nunca recibe nada que no podría haber
pedido él mismo con su sesión.
| Modo | Qué hace | Origen de la semántica |
|---|---|---|
HANDOFF | mover la reproducción de A a B, lo pida A, B o un tercero | dec-0090 |
TAKE_OVER | B se trae lo que suena en A ("continuar aquí") | variante de HANDOFF (r54 D2.3) |
RETURN_TO_ORIGIN | volver a A; A conserva un rato su estado pausado para volver sin re-planificar | variante de HANDOFF (r54 D2.3) |
ADD_CONTROLLER | un dispositivo se suma como mando; no reproduce ni crea generación | dec-0070 |
cmd.playback.handoff{handoffId, sessionId, from, to, mode}. playback-svc comprueba
§9 (el contenido cabe en el perfil efectivo del destino) y que el destino puede reproducirlo:
re-planifica para el destino (r05) con sus capacidades. El móvil pudo estar en HLS 1080p y
la TV recibe directo 4K HDR del mismo asset: se mueve la sesión, no la representación.evt.playback.handoffPrepare al origen; su CSC contesta con una
SessionStyxCapsule (dec-0094): asset/edición, posición en ticks, estado, rate, pistas
(TrackRef), desfase de subtítulos, cola y contexto, intent del plan, LogicalGenerationId y
last_issued (dec-0111 D8). El origen sigue reproduciendo.handoffLoad{capsule, startAt}, con startAt = posición de
la foto más la estimación del tiempo de preparación. El destino precarga desde ahí, calienta el
decoder y se queda pausado y listo: cmd.playback.handoffReady{handoffId, readyAt}.evt.playback.handoffCommit al origen; el origen pausa en un límite de frame e
informa la posición exacta P en ticks. playback-svc la pasa al destino.seekTo(P) (barato: precargó cerca) y reproduce. Su CSC
hereda la LogicalGenerationId de la cápsula (dec-0090) en vez de crear una; la sesión de
datos es nueva, la generación lógica es la misma.HANDOFF cierra su sesión de datos; RETURN_TO_ORIGIN la deja pausada
un tiempo acotado (reversible) y luego la cierra.Si el destino no está listo antes del plazo, falla al planificar o pierde el canal, playback-svc
aborta (evt.playback.handoffAborted): el origen no ha parado nunca, así que no hay hueco.
El progreso de visionado sigue indexado por (actorId, profileId) de la sesión (dec-0125 §4.3):
pasar de un dispositivo a otro no parte el "seguir viendo".
| Métrica | Definición |
|---|---|
| Hueco de handoff | desde que el origen deja de presentar hasta el primer frame del destino (p50/p95) |
| Error de posición | diferencia entre P y el primer frame presentado en el destino, en frames |
| Conservación | pistas de audio y subtítulos, desfase de subtítulos, cola y "siguiente episodio" iguales tras el handoff |
| Latencia de mando | desde la pulsación en el mando hasta el ack applied (p50/p95), misma LAN y LAN distintas |
| Tasa de abortos | handoffs abortados / pedidos, por causa |
Los umbrales no se fijan aquí (P6). Se miden contra YouTube "Ver en la TV" y Spotify Connect con el mismo contenido como vara.
puedeVer(principal, dispositivo) =
dispositivo.owner = cuenta(principal)
∨ (dispositivo.owner = hogar(principal))
∨ (dispositivo es personal de un miembro supervisado ∧ principal es manager del hogar) (P4)
puedeMandar(principal, dispositivo, comando) =
puedeVer(principal, dispositivo)
∧ can(principal, 'playback-session:control', sesión) (dec-0125 §5.1)
∧ (dispositivo.profileLocked ⇒ comando ∈ transporte ∪ pistas ∪ volumen ∨ perfil(principal).can_manage)
∧ (comando = load ⇒ contenido ∈ visible(perfil(principal)) ∩ visible(perfilEfectivo(destino)))dec-0125 §5: ningún eje amplía a otro. Un perfil infantil puede
pausar la TV del salón, pero no cargar en ella nada por encima de su madurez; y desde el perfil
adulto no se carga en la TV bloqueada en "Peques" nada que "Peques" no pueda ver.dec-0131 (federación, reservado), no este ADR.any, con el título oculto por defecto (P5). Mandar sobre dispositivos ajenos no está en
ROLE_GRANTS de nadie; cortar una sesión sí (revocación, dec-0118).@styx/authzdevice:read, device:rename, device:revoke, device:share (own; any para admin en
revoke), playback-session:read y playback-session:control (own; shared = hogar), y
playback-session:handoff (own; shared sólo hacia dispositivos del hogar). Se proyectan al
registro de operaciones de dec-0124 §6.1 con agentSafe: true para read y para los
comandos de transporte, y false para load y handoff (un agente no cambia lo que suena en la
TV sin confirmación).
control)El amigo en el salón que quiere usar su móvil de mando sin tener cuenta (como el código de TV de YouTube):
can_manage desde su móvil) aprueba.control (dec-0117 §4.1): aud = playback-svc
(no un daemon), resource = la sesión de reproducción, actor = el de la sesión, exp corto y
renovable mientras la TV siga aprobándolo, jti único. Se verifica con el verificador de spire
(@spire/bus/sct), no con código ad hoc.load,
ni catálogo, ni otros dispositivos. Se revoca desde la TV o al cerrar la sesión.Reutiliza el formato y el verificador que ya existen y da uso al scope control que el daemon ya
rechaza (y debe seguir rechazando: su aud nunca es un nodo daemon). Si waxin no lo quiere en v1,
queda fuera sin tocar nada más (P7).
| Amenaza | Mitigación |
|---|---|
| Un dispositivo revocado sigue mandando | device.revoke revoca sesiones (dec-0118) y realtime cierra el socket al invalidarse el principal (ya cierra a la caducidad, TS-P2-02) |
| Inundación de comandos o de fotos de estado | tope por mando y por sesión en playback-svc; realtime ya limita conexiones por actor; las fotos se fusionan (sólo cuenta la última) |
| Un destino miente sobre su estado | la foto sólo vale para la sesión de ese destino; no da acceso a nada. playback-svc no cree posiciones fuera de la duración ni generaciones ajenas |
| Repetición de comandos | commandId único y ventana de seq; el sobre de spire ya trae anti-replay entre servicios (dec-0119) |
| Filtración de qué ve cada uno | foto filtrada por perfil; dispositivos personales invisibles para el hogar salvo supervisión (P4); telemetría sin títulos ni ids en claro (A8) |
| Handoff hacia un dispositivo ajeno | puedeMandar sobre el destino; audit evt.security.handoffDenied |
| Mando invitado que escala | SCT control con resource = una sesión y lista cerrada de comandos; nunca se intercambia por JWT |
| Superficie | Riesgo | Mitigación |
|---|---|---|
SSDP/UPnP en el lan-bridge | SSRF: la URL LOCATION del anuncio apunta a un servicio interno; XXE en el XML de descripción | sólo se sigue LOCATION a direcciones privadas o link-local de la misma subred del anuncio; sin redirecciones; tamaño y tiempo acotados; parser XML sin entidades externas; nunca una URL que llegue de un cliente |
| mDNS/SSDP falsificados | un dispositivo se anuncia como "TV del salón" para recibir lo que se envíe | un destino externo se registra con el nombre que anuncia y el bridge que lo vio; el usuario confirma el primer envío a un destino nuevo; los destinos no se comparten entre hogares |
| URLs firmadas que se llevan los receptores | el token de la URL acaba en logs del receptor o de la red | SCT read ligada a la sesión del cast, a un asset y a un rango de pistas; revocable por unbind del sid (la revocación de flujos largos ya existe); nunca credenciales de cuenta ni URLs de almacenamiento |
| Receptores que no renuevan (DLNA, Default Media Receiver) | la SCT read de ≤ 10 min corta la película (el mismo bloqueante que la vertical tiene con SZ13) | o renovación en el delivery-edge (la URL lleva un token de sesión de cast; el edge renueva la SCT con el daemon) o SCT de vida = duración + margen; la elección es P9 |
| Bridge comprometido | inventa destinos o escucha lo que se envía en su LAN | el bridge es un dispositivo con rol bridge y scope device:bridge del hogar; sólo registra destinos para su hogar; sus credenciales se revocan como las de cualquier dispositivo |
| Mirroring (HDCP, licencias, Miracast) | campo de minas legal | fuera, como r60 D7 |
Un destino sin cliente Styx entra en playback-svc como externalTarget:
ExternalTarget {
id, protocol: 'google-cast' | 'airplay' | 'upnp-av' | 'dial' | 'matter-casting'
name, model?, capabilities // lo que el protocolo deja saber (códecs, contenedores, subtítulos)
discoveredBy: { kind: 'browser' | 'native-app' | 'lan-bridge', deviceId }
scope: { household | actor }, sameNetworkAs: deviceId[], expiresAt // TTL corto, se renueva al re-anunciarse
}El mando ve los dispositivos Styx y los destinos externos en una sola lista. Lo que cambia por debajo es quién habla con el destino (el "adaptador") y por dónde le llegan los bytes. Los conceptos de los protocolos externos no entran en el dominio (candidato 02: "External protocol concepts no deben entrar en Canonical Media").
| Desde | Destinos que alcanza | Componente |
|---|---|---|
| Web, Chrome escritorio/Android | Google Cast | Cast Web Sender (CAF) con el receptor de Styx (§11.4); o Remote Playback API en Chrome Android. En la fase de players vendorizados (V-N02) vidstack trae botones de Google Cast y AirPlay: se usan tal cual con una fuente HLS por URL de delivery-edge. |
| Web, Safari (macOS/iOS) | AirPlay | Remote Playback API / webkitShowPlaybackTargetPicker. El player ofrece una <source> HLS por URL además de MSE/MMS, porque con MSE no hay cast. |
| Web, cualquier navegador | DLNA/UPnP, DIAL, Cast desde navegadores sin Cast, Matter | No puede descubrir la LAN (sin SSDP ni mDNS). Los ve en la lista porque un lan-bridge de su hogar los anunció, y el envío lo ejecuta ese bridge (§11.5). |
| App Swift (iOS/iPadOS/macOS/tvOS) | AirPlay, Google Cast, DLNA, Matter Casting (después) | AVRoutePickerView + AVPlayer.allowsExternalPlayback; Google Cast iOS SDK; el DLNA "RemotePlay" que ya tiene (V-DEV-05, se porta en F-FUSION); NWBrowser para Bonjour (permiso de red local). Además actúa de lan-bridge mientras está abierta. MPNowPlayingInfoCenter/MPRemoteCommandCenter con la sesión remota. |
| Android / Android TV (futuro) | Google Cast, Matter Casting | MediaRouter + Cast SDK; cliente Matter Casting. |
Pi 3 (r59, track/appliance-pi3) | — | Es destino Styx nativo y puede ser lan-bridge del hogar (siempre encendido). |
| Servidor (sidecar opcional) | DLNA/UPnP, DIAL | styx-lan-bridge: binario Bun compilado (dec-0123) desplegado junto al servidor con red de host (multicast). Sólo si el servidor está en la LAN de la TV. No es un servicio de r17: es un dispositivo con rol bridge. |
delivery-edgeLos receptores externos descargan por HTTP/1.1 o HTTP/2 sobre TCP: el daemon sólo habla HTTP/3.
delivery-edge (r17, sin casa; su decisión de origen TCP no tiene ADR, V-PLAY-07) traduce una
sesión de cast a recursos que el receptor entiende: HLS/fMP4 o MP4 progresivo con rangos, perfiles
DLNA (protocolInfo), subtítulos convertidos (WebVTT para Cast/AirPlay, SRT para DLNA), CORS para
el receptor web, artwork por URL. Los bytes siguen saliendo del daemon (r01): el edge no
transcodifica, pide al daemon la representación que el plan decidió. El cast depende de que
exista el origen TCP; sin él, la fase B no arranca (P10).
El receptor web propio (CAF) que carga el Chromecast/Google TV es una página de Styx: al cargar
se identifica como dispositivo cast-receiver (credencial efímera emitida por playback-svc para esa
sesión de cast, no la cuenta del usuario) y abre el canal de realtime. Desde ese momento el
Chromecast es un destino Styx: tiene presencia, foto con metadatos, mando desde cualquier
dispositivo de la cuenta (no sólo desde el que lanzó el cast) y handoff. El receptor por defecto de
Google queda como respaldo sin esas ventajas.
lan-bridgeRol de dispositivo, no servicio. Hace:
M-SEARCH (urn:schemas-upnp-org:device:MediaRenderer:1, DIAL) y DNS-SD
(_googlecast._tcp, _airplay._tcp, _matter._tcp cuando toque). Registra los destinos en
playback-svc para su hogar, con TTL.SetAVTransportURI con la URL de
delivery-edge y DIDL-Lite (título, portada: la TV DLNA también enseña los metadatos), Play,
Pause, Seek, y sondeo de GetPositionInfo para que el destino DLNA tenga presencia y foto
en la lista como un dispositivo Styx. DIAL: lanzar la app de Styx en la TV y pasar a handoff
nativo.Resultado del planificador por destino, como el candidato 02: SESSION_HANDOFF, DIRECT_REMOTE,
REPACKAGE_REMOTE, TRANSCODE_REMOTE o UNSUPPORTED (con el porqué, r05).
@styx/api-contractsdevices.ts: DeviceSchema, DeviceKind, DeviceRole, RemoteCapabilitiesSchema,
DeviceRenameBody, DeviceShareBody.remote.ts: NowPlayingSchema (§6.2), TrackRefSchema, MediaTimeSchema (ticks +
timescale), RemoteCommandSchema (unión discriminada de §7.1), RemoteAckSchema,
HandoffRequest/Prepare/Load/Ready/Commit/AbortedSchema, ExternalTargetSchema (fase B).schemaVersion, separados de los del
bus: el cliente nunca ve un sobre de spire.BUS_ROUTES, por spire)| Subject | Tipo | Emisor → atiende |
|---|---|---|
evt.realtime.deviceConnected / deviceDisconnected | evt | realtime → playback |
cmd.playback.reportState | cmd | realtime → playback |
cmd.playback.remoteCommand / remoteAck | cmd | realtime → playback |
cmd.playback.handoff / handoffReady / handoffPosition | cmd | realtime → playback |
evt.playback.nowPlaying / remoteCommand / remoteAck / handoffPrepare / handoffLoad / handoffCommit / handoffAborted | evt | playback → realtime (filtrado por audiencia) |
qry.playback.devices (lista con presencia y foto) | qry | realtime, web BFF → playback |
qry.identity.device (dueño, hogar, perfil bloqueado) | qry | playback → identity |
evt.identity.deviceRevoked / deviceShared | evt | identity → playback, realtime |
evt.security.handoffDenied / remoteDenied / controlGrantIssued | evt | playback → sink de audit (dec-0119 §8) |
realtime pasa a emitir cmd.playback.* y evt.realtime.*: es la enmienda de §14.2, y la
ACL de NATS se genera de esta tabla como el resto (dec-0120).
| Operación | Ruta (vía BFF en la web) | CLI |
|---|---|---|
device.list / .rename / .revoke / .share | /devices, /devices/:id | styx device list|rename|revoke|share |
playback.nowPlaying | GET /playback/now-playing | styx now --json |
playback.remote | POST /playback/devices/:id/commands | styx remote <device> pause|seek … |
playback.handoff | POST /playback/sessions/:id/handoff | styx handoff <session> --to <device> |
playback.controlGrant (P7) | POST /playback/sessions/:id/control-grants | — |
La ruta HTTP de comandos existe para el CLI y los agentes; la web y las apps usan el canal de
realtime. Las dos acaban en el mismo cmd.playback.remoteCommand.
V-DEV-01): no, de momento. Lo que la haría posible (reloj compartido) va por
MoQT (§4.3) y tendrá su ADR.V-N10, MOVE_OFFLINE): es dec-0129 (transferencias, reservado).dec-0131 (federación, reservado).V-N02): este ADR sólo exige que cualquier player (vendorizado o
propio) tenga CSC, informe su estado y acepte comandos por el canal.dec-0069/dec-0070/dec-0090 (se aplican tal cual: §7.2, §8.2), dec-0094 (la cápsula del
handoff), dec-0117 (SCT v1 sin cambio de formato), dec-0118 (BFF, CSRF, revocación, audit),
dec-0119/dec-0120 (todo por spire), r19 (los adaptadores de protocolo siguen en clientes y
bridge), r59 (el Pi 3 es cliente, no sistema).
V-DEV-03 y el "threat model separado" es §10. MIRROR
sigue fuera.cmd.playback.* y evt.realtime.*. Se mantiene: sin presence guardada, sin snapshots, sin
estado de dominio, superficie filtrada (r20 §3.2).devices y device_id en las sesiones; device.share y el
dispositivo del hogar.read de cast ligada a
la duración.V-DEV-04)Fase A0 (contratos y registro de dispositivos) es barata y cabe en la vertical o justo después: deja el canal, la foto y los subjects puestos para que los clientes nazcan con ellos.
Decidido por waxin (2026-10-02, P2): A0 (registro, presencia y «reproduciendo ahora») entra justo después del MVP, no en
outcome/first-vertical. El MVP no gana items de gate de dispositivos; A0 es lo primero que se hace al cerrarlo. Dónde vive en el model sigue siendo P1.
A1 (mando) y A2 (handoff) dependen del CSC del player en uso, no de un player concreto, y de
las sesiones con dispositivo de dec-0125. Es lo mismo que pide §13 y lo que fija N2 (players
vendorizados primero, el propio después):
dec-0069) envuelve a limeplay o vidstack en
apps/web, que es donde la web ya reproduce contra playback-svc (track/ui/u2, item U6). Ese
CSC crea las generaciones, aplica los comandos de §7 como entrada y produce la foto de §6.2. El
player vendorizado sólo ejecuta lo que el CSC le manda. Por eso, en el model, track/devices/a1
depende de track/ui/u2 (y a2 lo hereda por a1).track/web-player-v1). Implementa el mismo contrato de CSC y
recibe A1 y A2 sin tocar el canal ni el vocabulario. track/web-player-v1 W3 (timeline, seek y
generaciones) no es requisito de A1 ni de A2. Si lo fuera, el mando y el handoff esperarían
a la iteración posterior que N2 deja para después.La fase B depende del origen TCP de delivery-edge.
Consecuencia: el CSC tiene diseño (track/web-player-v1#03, el mapa de autoridad de
track/client-platform-foundation#01), pero atado al player propio y al núcleo de cliente. Ningún
ticket lo construye sobre el player vendorizado. Hace falta un ticket en track/ui/u2 (o en
track/devices/a1 si waxin lo prefiere), que queda como pregunta (P13).
track/devices (zone cross) con
milestones a0 contratos + registro + presencia + foto, a1 mando remoto, a2 handoff, b0
cast web (Remote Playback API + Cast sender + receptor Styx), b1 lan-bridge (DLNA/DIAL),
b2 app Swift (AirPlay/Cast/DLNA portado en F-FUSION). ¿O milestones dentro de
track/client-platform-foundation y outcome/swift-client-integration? El nodo recomendado ya
está en styx.model.yml como alta propuesta queued, sin gate; si eliges la alternativa, se borra.outcome/first-vertical) o justo después?control (§9.3): ¿en v1, después o nunca?delivery-edge (recomendado: la SCT sigue
siendo corta) o SCT read con vida = duración?delivery-edge: la fase B necesita el origen TCP. ¿Se decide ese ADR
(V-PLAY-07) antes de b0?track/ui/u2, porque apps/web ya reproduce ahí, del que dependen track/devices/a1 y a2. La
alternativa es construirlo dentro de track/devices/a1. En los dos casos el player propio
(track/web-player-v1) hereda el mismo contrato y no bloquea a A1 ni a A2.| Pieza | Consumidor |
|---|---|
| Registro de dispositivos | Ajustes → Dispositivos (web), styx device, quick connect de TV (dec-0125 §10) |
| Presencia + foto | lista de dispositivos y mini-player remoto de la web; app Swift (pantalla de bloqueo) |
| Mando | móvil web controlando la web de la TV o del Mac; styx remote |
| Handoff | "Continuar en la TV" / "Continuar aquí" en la web y la app |
| Cast fase B | botón de cast del player (vidstack primero, V-N02), lista unificada de destinos |
Página especificado: apps/docs/content/docs/explicacion/dispositivos-mando-y-handoff.mdx,
atada a este ADR y a track/docs:DC9.
load por referencia de catálogo, nunca URL; cast detrás del registro de destinos y de
delivery-edge; MoQT fuera del mando; mirroring fuera.load, el transporte del canal
(WS → WebTransport), y la lista de protocolos de la fase B.delivery-edge. Es deliberado: sin origen TCP no hay cast que no sea
un atajo.