dec-0128

Vista generada de dec-0128: Dispositivos, presencia, mando remoto, handoff y capa de cast

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0128-dispositivos-presencia-mando-remoto-handoff-cast.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0128-dispositivos-presencia-mando-remoto-handoff-cast.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoPROPOSED
Fecha2026-10-01
Ficherodocs/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)

Texto del ADR

Leído de docs/decisions/dec-0128-dispositivos-presencia-mando-remoto-handoff-cast.md, el fichero canónico.

dec-0128 — Dispositivos, presencia, mando remoto, handoff y capa de cast

  • Fecha: 2026-10-01
  • Estado: PROPOSED. No es un lock. waxin lo lockea vía 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.
  • Nota de decisión de waxin (2026-10-02, vía 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).
  • Nota de decisión de waxin (2026-10-02, respuestas de la sesión de orquestación; no lockea el ADR): P3 resuelta. El dispositivo del hogar reproduce como quien lo controla (su cuenta, su perfil y su progreso), estilo Spotify Connect: la opción recomendada en §15. El ADR sigue PROPOSED.
  • Propone: workflow de visión (rama 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).
  • Dirección de waxin que cumple (2026-10-01, N5). Ids del ledger (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.
    • Toca también V-N02 (players vendorizados primero, §11.2) y V-N10 (offline, fuera: §13).
  • Cita:
    • 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).

1. Contexto: qué existe hoy

Verificado sobre el árbol de w10/vision (base w9/docs sobre 922dedc).

PiezaEstadoDónde
realtime-svc: WS /ws detrás de la policy authenticated, Origin en allowlist, topes global y por actor, cierre a la caducidad del principalexisteapps/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 memoriaexiste (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 actorexisteapps/playback-svc/src/core/handlers/PlaybackHandler.ts (TS-P1-01, sec#21)
CapabilityScope = 'read' | 'publish' | 'ingest' | 'control'; el daemon rechaza ingest y control en MoQTexisteapps/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ódigodec-0070, dec-0090, r54 D2.3
SessionStyxCapsulelock, sin códigodec-0094, track/offline-continuity (queued)
Remote Playback Fabric (modos, roles, Delivery Gateway, preferencia de protocolos)candidatodocs/design/styx-next-wave-architecture-candidates/02-remote-playback-session-mobility-and-bridge.md
Sesiones browser/native/device con perfil, deviceName, profileLocked; device.list/.revokePROPOSEDdec-0125 §4.3, §10, §11.1
App Swift: DLNA/Cast "RemotePlay" funcionando, AVPlayer con AirPlayfuera del repor19 (inventario de MKS-IPTV-App), apps/apple/README.md (TARGET post F-FUSION, sin código)
Origen TCP para receptores que no hablan HTTP/3faltael 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, castnuevoeste ADR

Tres restricciones del estado actual que este ADR trata de frente:

  1. realtime-svc no guarda presence (lock F0.A.3, README §Prohibido). Este ADR lo mantiene: la presencia vive en playback-svc (§3) y realtime sólo informa de conexiones.
  2. realtime-svc es consumer-only. El mando necesita el camino cliente → servidor. Se propone la enmienda acotada de §14.2: realtime pasa a ser pasarela bidireccional sin estado.
  3. r60 D7 difiere el cast externo. waxin lo pide ahora como fase posterior (V-DEV-03). §11 es el diseño y §10 el threat model separado que D7 exigía.

2. Referencias estudiadas y qué se toma

ReferenciaQué haceQué se toma / qué no
Spotify ConnectEl 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 APIGET /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 / GDMReproductores 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 ReceiverSender 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.
DIALDescubrimiento 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.

3. Decisión (resumen)

  1. Dispositivo = entidad de identity-svc. Cada instalación de cliente (pestaña de navegador registrada, app, TV, Pi 3, receptor Cast de Styx) es un 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).
  2. playback-svc es el coordinador de sesiones (el Session Coordinator del candidato 02): la autoridad de qué se reproduce, dónde y para quién. Guarda la presencia y el "reproduciendo ahora" como estado efímero en Valkey y autoriza cada comando y cada handoff (§6, §7, §8).
  3. realtime-svc es la pasarela de dispositivos: un canal por dispositivo, bidireccional y sin estado propio más allá del registro de sockets que ya tiene. Lo que entra lo convierte en cmd.*; lo que sale lo filtra por audiencia (§4, enmienda §14.2).
  4. El dispositivo destino manda sobre su presentación (r54): un comando remoto es una entrada para su CSC, que es quien crea la generación nueva si hace falta. Cada comando lleva id, secuencia y generación esperada, y tiene ack (§7).
  5. Handoff make-before-break con posición exacta: el destino prepara desde la posición del origen mientras éste sigue; al confirmar, el origen pausa, informa la posición exacta en ticks y el destino arranca desde ahí heredando la LogicalGenerationId (dec-0090). Si el destino no llega a estar listo, el origen no se entera: no hay hueco (§8).
  6. Seguridad por intersección como 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).
  7. MoQT no se usa para el mando en v1. El mando es control plane (r01) y realtime ya tiene el canal autenticado. MoQT se queda para media (§4.3).
  8. Cast desacoplado y después: los destinos sin cliente Styx entran en el mismo registro como 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).

4. Arquitectura: quién hace qué

  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)

4.1 Reparto de autoridad

CosaAutoridadPor qué
Existencia, dueño, nombre y revocación del dispositivoidentity-svcEs 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 perfilplayback-svcYa 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 reproducer54 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, Originrealtime-svcYa 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 externosdelivery-edge (+ daemon)Candidato 02 §Delivery Gateway; r17 ya reserva el servicio.

4.2 El canal del dispositivo

  • Hoy: el WS /ws de realtime-svc. Mensajes JSON pequeños, validados con TypeBox (@styx/api-contracts, dec-0116), con versión de esquema en cada mensaje.
  • Después: WebTransport sobre el mismo origen QUIC que delivery-edge, sin cambiar los mensajes (el contrato es el mismo con otro transporte).
  • Un dispositivo abre un canal. Por él recibe comandos y eventos de su audiencia y envía su estado. Una pestaña de la web que sólo mira (mando) también abre canal, pero no se anuncia como destino salvo que tenga un player activo.

4.3 Por qué no MoQT para el mando

  • El daemon es data plane (r01, "Bun decide, Zig ejecuta"): meter ahí la autorización de comandos y la presencia duplicaría la autoridad de playback-svc.
  • El tráfico es pequeño y esporádico (un comando, una foto de estado por segundo mientras se reproduce): no necesita el fanout de MoQT.
  • Se reserva para cuando haga falta un reloj compartido de presentación (watch party, V-DEV-01, fuera por ahora). Si llega, será una pista MoQT de reloj, no el canal de mando.

4.4 Lo que nunca cruza el canal

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).

5. Registro de dispositivos

5.1 Entidad

CampoValor
iduuid 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)
nameeditable ("TV del salón", "Chrome en el Mac")
kindtv | phone | tablet | desktop | browser | appliance | cast-receiver | cli
platformweb | ios | ipados | macos | tvos | android | androidtv | pi3 | chromecast | other
rolessubconjunto de controller (puede mandar), target (puede reproducir), bridge (lan-bridge, §11.5)
capabilitiesRefClientCapabilities versionadas (r20) + remote: { commands[], maxRate? } que el destino declara (§7.1)
defaultProfileId, profileLockedde dec-0125 §6 y §10.4
createdAt, lastSeenAtlastSeenAt se actualiza como mucho cada pocos minutos (no por heartbeat)
  • Tabla 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.
  • Un navegador es un dispositivo cuando el usuario lo nombra o reproduce en él por primera vez; la web no crea un dispositivo por cada pestaña: comparten el id del navegador (guardado en la sesión del BFF, no en 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.

5.2 Dispositivo personal o del hogar

  • Personal (por defecto): de una cuenta. Lo ve y lo manda esa cuenta.
  • Del hogar: la TV del salón. Lo ve y lo manda cualquier miembro del hogar según §9. Cuando un miembro reproduce en él, la sesión de reproducción es de ese miembro (su cuenta, su perfil, su progreso), no del dispositivo (P3). Es el modelo de Spotify: el altavoz suena con la cuenta de quien lo controla.

6. Presencia y "reproduciendo ahora"

6.1 Presencia

  • realtime emite 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.
  • Estados: online (canal abierto), playing/paused/buffering/idle (si es target), offline (sin canal; aparece con lastSeenAt si se pide).
  • Mismo servidor desde otras LAN: no hay nada especial que hacer. Todo dispositivo con canal abierto contra el servidor aparece, esté donde esté. sameNetwork sólo pinta la insignia "en tu red / en otra red".

6.2 La foto "reproduciendo ahora"

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.
  • En directo, el "reproduciendo ahora" es el canal y el programa de la EPG (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).
  • El mando interpola la posición con observedAt y rate entre fotos: la barra se mueve suave sin pedir una foto por frame.

7. Mando remoto

7.1 Vocabulario v1

FamiliaComandosNotas
Transporteplay, pause, togglePause, stop, seekTo{ticks,timescale}, seekBy{ms}, setRateseekBy relativo se resuelve en el destino; la idempotencia evita aplicarlo dos veces
PistassetAudioTrack{TrackRef}, setSubtitleTrack{TrackRef | null}, setSubtitleOffset{ms}
VolumensetVolume{0..1}, mute, unmuteDel player, no del sistema. Volumen de sistema sólo si el destino lo declara (TV con CEC, P8)
Contenidoload{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
SaltosskipSegment{kind: 'intro' | 'credits' | 'recap'}Depende de V-N04 (intro/créditos, sin nodo): el comando existe sólo si el destino lo declara
Navegaciónnav{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.

7.2 Semántica

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──► mandos
  • Idempotente por commandId: un reintento del mando no aplica dos veces un seekBy.
  • Orden por seq por par (mando, sesión): un comando más viejo que el último aplicado de ese mando se descarta como superseded.
  • Generación esperada opcional: un 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.
  • Varios mandos a la vez: gana el último que llega al coordinador (serialización en playback-svc por sesión). Todos ven el resultado por la foto. No hay bloqueo de "un solo mando".
  • Ack con plazo: si el destino no contesta en un plazo corto, el mando pinta "la TV no responde" y no reintenta solo. Es lo que Jellyfin no tiene (comando 204 sin saber qué pasó).
  • UI optimista: el mando aplica el cambio en su vista al pulsar y lo reconcilia con el ack.

7.3 load: cambiar el contenido

load 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.

8. Handoff

8.1 Modos

ModoQué haceOrigen de la semántica
HANDOFFmover la reproducción de A a B, lo pida A, B o un tercerodec-0090
TAKE_OVERB se trae lo que suena en A ("continuar aquí")variante de HANDOFF (r54 D2.3)
RETURN_TO_ORIGINvolver a A; A conserva un rato su estado pausado para volver sin re-planificarvariante de HANDOFF (r54 D2.3)
ADD_CONTROLLERun dispositivo se suma como mando; no reproduce ni crea generacióndec-0070

8.2 Protocolo (make-before-break)

  1. Pedir: 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.
  2. Foto del origen: 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.
  3. Preparar el destino: playback-svc abre la sesión de reproducción del destino (sid nuevo, SCT nueva para el destino) y le manda 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}.
  4. Confirmar: 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.
  5. Arrancar: el destino hace 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.
  6. Cerrar el origen: 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".

8.3 "Mejor que YouTube": qué se mide

MétricaDefinición
Hueco de handoffdesde que el origen deja de presentar hasta el primer frame del destino (p50/p95)
Error de posicióndiferencia entre P y el primer frame presentado en el destino, en frames
Conservaciónpistas de audio y subtítulos, desfase de subtítulos, cola y "siguiente episodio" iguales tras el handoff
Latencia de mandodesde la pulsación en el mando hasta el ack applied (p50/p95), misma LAN y LAN distintas
Tasa de abortoshandoffs 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.

9. Seguridad: quién ve y quién manda

9.1 Reglas

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)))
  • Intersección siempre, como 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.
  • Perfil efectivo del destino: el perfil bloqueado si lo está; si no, el perfil de la sesión que se va a crear (el de quien manda, §5.2).
  • Ver no es ver el título: la foto se filtra por el perfil del que mira (§6.2).
  • Cuentas sin relación: no se ven ni se mandan. Nunca. Para compartir entre servidores está dec-0131 (federación, reservado), no este ADR.
  • Admin de servidor: ve las sesiones activas en el panel de operación (como Jellyfin) por 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).

9.2 Permisos nuevos en @styx/authz

device: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).

9.3 Mando invitado sin cuenta (SCT 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):

  1. La TV muestra "Usar otro móvil como mando" → QR con un código de un solo uso.
  2. Quien está en la TV (o un can_manage desde su móvil) aprueba.
  3. playback-svc emite una SCT v1 de scope 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.
  4. Con ella el invitado sólo envía transporte, pistas y volumen sobre esa sesión. Ni 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).

9.4 Abusos y límites

AmenazaMitigación
Un dispositivo revocado sigue mandandodevice.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 estadotope 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 estadola 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 comandoscommandId único y ventana de seq; el sobre de spire ya trae anti-replay entre servicios (dec-0119)
Filtración de qué ve cada unofoto 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 ajenopuedeMandar sobre el destino; audit evt.security.handoffDenied
Mando invitado que escalaSCT control con resource = una sesión y lista cerrada de comandos; nunca se intercambia por JWT

10. Threat model del cast (el "separado" que pide r60 D7)

SuperficieRiesgoMitigación
SSDP/UPnP en el lan-bridgeSSRF: la URL LOCATION del anuncio apunta a un servicio interno; XXE en el XML de descripciónsó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 falsificadosun dispositivo se anuncia como "TV del salón" para recibir lo que se envíeun 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 receptoresel token de la URL acaba en logs del receptor o de la redSCT 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 comprometidoinventa destinos o escucha lo que se envía en su LANel 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 legalfuera, como r60 D7

11. Capa de cast (fase B, desacoplada)

11.1 Destino externo en el mismo registro

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").

11.2 Qué componente lo hace, por plataforma

DesdeDestinos que alcanzaComponente
Web, Chrome escritorio/AndroidGoogle CastCast 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)AirPlayRemote Playback API / webkitShowPlaybackTargetPicker. El player ofrece una <source> HLS por URL además de MSE/MMS, porque con MSE no hay cast.
Web, cualquier navegadorDLNA/UPnP, DIAL, Cast desde navegadores sin Cast, MatterNo 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 CastingMediaRouter + 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, DIALstyx-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.

11.3 Delivery Gateway = delivery-edge

Los 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).

11.4 Receptor Cast de Styx

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.

11.5 lan-bridge

Rol de dispositivo, no servicio. Hace:

  • Descubrir: SSDP 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.
  • Controlar lo que un navegador no puede: UPnP AV 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.
  • No hace: AirPlay (sólo desde Apple), mirroring, ni Google Cast por CASTV2 desde fuera del SDK oficial salvo que waxin acepte el riesgo de términos de uso (P11).

11.6 Orden de preferencia

  1. Destino Styx nativo (app, Pi 3, web): handoff completo.
  2. Receptor Cast de Styx: handoff completo vía Chromecast.
  3. Cast/AirPlay oficiales (receptor por defecto, AirPlay): carga remota con mando.
  4. DLNA vía bridge: carga remota con mando y presencia por sondeo.
  5. DIAL: sólo lanzar la app y volver al punto 1.
  6. Mirroring: no.

Resultado del planificador por destino, como el candidato 02: SESSION_HANDOFF, DIRECT_REMOTE, REPACKAGE_REMOTE, TRANSCODE_REMOTE o UNSUPPORTED (con el porqué, r05).

12. Contratos a añadir (sin implementar)

12.1 @styx/api-contracts

  • devices.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).
  • Mensajes del canal de realtime (cliente ↔ realtime) con schemaVersion, separados de los del bus: el cliente nunca ve un sobre de spire.

12.2 Bus (BUS_ROUTES, por spire)

SubjectTipoEmisor → atiende
evt.realtime.deviceConnected / deviceDisconnectedevtrealtime → playback
cmd.playback.reportStatecmdrealtime → playback
cmd.playback.remoteCommand / remoteAckcmdrealtime → playback
cmd.playback.handoff / handoffReady / handoffPositioncmdrealtime → playback
evt.playback.nowPlaying / remoteCommand / remoteAck / handoffPrepare / handoffLoad / handoffCommit / handoffAbortedevtplayback → realtime (filtrado por audiencia)
qry.playback.devices (lista con presencia y foto)qryrealtime, web BFF → playback
qry.identity.device (dueño, hogar, perfil bloqueado)qryplayback → identity
evt.identity.deviceRevoked / deviceSharedevtidentity → playback, realtime
evt.security.handoffDenied / remoteDenied / controlGrantIssuedevtplayback → 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).

12.3 HTTP y paridad headless (dec-0124 §6.1)

OperaciónRuta (vía BFF en la web)CLI
device.list / .rename / .revoke / .share/devices, /devices/:idstyx device list|rename|revoke|share
playback.nowPlayingGET /playback/now-playingstyx now --json
playback.remotePOST /playback/devices/:id/commandsstyx remote <device> pause|seek …
playback.handoffPOST /playback/sessions/:id/handoffstyx 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.

13. Fuera de alcance

  • Watch party (V-DEV-01): no, de momento. Lo que la haría posible (reloj compartido) va por MoQT (§4.3) y tendrá su ADR.
  • Offline y descargas (V-N10, MOVE_OFFLINE): es dec-0129 (transferencias, reservado).
  • Compartir con otros servidores: dec-0131 (federación, reservado).
  • Mirroring: fuera (r60 D7).
  • El diseño del player propio (V-N02): este ADR sólo exige que cualquier player (vendorizado o propio) tenga CSC, informe su estado y acepte comandos por el canal.

14. Encaje con lo decidido

14.1 Sin cambios

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).

14.2 Enmiendas propuestas (entran con el lock de este ADR)

  1. r60 D7: el cast externo pasa de DEFERRED a fase B de este ADR, después del mando y el handoff nativos. La "demanda real" es V-DEV-03 y el "threat model separado" es §10. MIRROR sigue fuera.
  2. realtime-svc (lock F0.A.3, README): de consumer-only a pasarela bidireccional que emite cmd.playback.* y evt.realtime.*. Se mantiene: sin presence guardada, sin snapshots, sin estado de dominio, superficie filtrada (r20 §3.2).
  3. dec-0125 §4.3: la tabla devices y device_id en las sesiones; device.share y el dispositivo del hogar.
  4. dec-0117 §4.1 (sólo si waxin elige esa opción en P9): vida de la SCT read de cast ligada a la duración.

14.3 Encaje con la vertical (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):

  • Primero, el player vendorizado. El CSC (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).
  • Después, el player propio (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).

15. Preguntas para waxin (bloquean el lock)

  • P1 — Dónde vive en el model. Recomendado: nodo nuevo 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.
  • P2 — ¿A0 entra en la vertical (outcome/first-vertical) o justo después? Resuelta (waxin, 2026-10-02): justo después del MVP (§14.3).
  • P3 — Dispositivo del hogar: ¿la TV del salón reproduce como quien la controla (su cuenta, su perfil, su progreso), recomendado, o como la cuenta con la que se dio de alta? Resuelta (waxin, 2026-10-02): como quien la controla, estilo Spotify Connect.
  • P4 — Privacidad en el hogar: ¿los dispositivos personales son invisibles para el resto del hogar (recomendado) y sólo los managers ven a los supervisados?
  • P5 — Panel de admin: ¿ve los títulos de lo que reproduce cada cuenta (Jellyfin lo enseña) o sólo que hay sesión (recomendado)?
  • P6 — Umbrales de hueco de handoff, error de posición y latencia de mando que harían de esto un gate. Se propone medir primero contra YouTube y Spotify con el mismo contenido.
  • P7 — Mando invitado con SCT control (§9.3): ¿en v1, después o nunca?
  • P8 — Navegación D-pad y volumen de sistema (TV, Pi 3 por CEC): ¿en v1?
  • P9 — Receptores que no renuevan: ¿renovación en delivery-edge (recomendado: la SCT sigue siendo corta) o SCT read con vida = duración?
  • P10 — Orden frente a delivery-edge: la fase B necesita el origen TCP. ¿Se decide ese ADR (V-PLAY-07) antes de b0?
  • P11 — Google Cast desde el bridge por CASTV2 (sin SDK oficial): ¿se descarta por términos de uso (recomendado) o se explora?
  • P12 — Matter Casting: ¿spike en la fase B (frontier, r16) o se espera a más soporte de TVs?
  • P13 — Dónde nace el CSC sobre el player vendorizado (§14.3). Recomendado: un ticket de 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.

16. Consumidor real de cada pieza (r28) y doc

PiezaConsumidor
Registro de dispositivosAjustes → Dispositivos (web), styx device, quick connect de TV (dec-0125 §10)
Presencia + fotolista de dispositivos y mini-player remoto de la web; app Swift (pantalla de bloqueo)
Mandomó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 Bbotó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.

17. Qué fija el lock y qué queda reversible

  • Fija: reparto de autoridad (§4.1); presencia en playback-svc y no en realtime; mando como entrada al CSC con ack, idempotencia y generación esperada; handoff make-before-break con posición en ticks y herencia de generación; intersección de perfiles para ver y mandar; 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.
  • Reversible: TTL y latidos, plazos de ack y de handoff, frecuencia de fotos, el vocabulario exacto de comandos más allá de transporte, pistas, volumen y load, el transporte del canal (WS → WebTransport), y la lista de protocolos de la fase B.

18. Consecuencias

  • Los clientes nacen con un canal y un contrato de mando únicos: la web, la app Swift, el Pi 3 y el receptor Cast hablan lo mismo, y el CLI y los agentes llegan por HTTP a la misma operación.
  • playback-svc crece: coordinador de sesiones con estado efímero en Valkey. Es su dominio, pero sube su carga y su superficie; los topes de §9.4 son parte del diseño, no un añadido.
  • realtime-svc deja de ser consumer-only: su ACL de NATS cambia y su test de superficie pública (r20 §3.2) debe cubrir el camino de entrada.
  • El cast queda bloqueado por delivery-edge. Es deliberado: sin origen TCP no hay cast que no sea un atajo.
  • La app Swift aporta su DLNA y su AirPlay en F-FUSION en lugar de reescribirlos.
Texto del ADR
dec-0128 — Dispositivos, presencia, mando remoto, handoff y capa de cast
1. Contexto: qué existe hoy
2. Referencias estudiadas y qué se toma
3. Decisión (resumen)
4. Arquitectura: quién hace qué
4.1 Reparto de autoridad
4.2 El canal del dispositivo
4.3 Por qué no MoQT para el mando
4.4 Lo que nunca cruza el canal
5. Registro de dispositivos
5.1 Entidad
5.2 Dispositivo personal o del hogar
6. Presencia y "reproduciendo ahora"
6.1 Presencia
6.2 La foto "reproduciendo ahora"
7. Mando remoto
7.1 Vocabulario v1
7.2 Semántica
7.3 load: cambiar el contenido
8. Handoff
8.1 Modos
8.2 Protocolo (make-before-break)
8.3 "Mejor que YouTube": qué se mide
9. Seguridad: quién ve y quién manda
9.1 Reglas
9.2 Permisos nuevos en @styx/authz
9.3 Mando invitado sin cuenta (SCT control)
9.4 Abusos y límites
10. Threat model del cast (el "separado" que pide r60 D7)
11. Capa de cast (fase B, desacoplada)
11.1 Destino externo en el mismo registro
11.2 Qué componente lo hace, por plataforma
11.3 Delivery Gateway = delivery-edge
11.4 Receptor Cast de Styx
11.5 lan-bridge
11.6 Orden de preferencia
12. Contratos a añadir (sin implementar)
12.1 @styx/api-contracts
12.2 Bus (BUS_ROUTES, por spire)
12.3 HTTP y paridad headless (dec-0124 §6.1)
13. Fuera de alcance
14. Encaje con lo decidido
14.1 Sin cambios
14.2 Enmiendas propuestas (entran con el lock de este ADR)
14.3 Encaje con la vertical (V-DEV-04)
15. Preguntas para waxin (bloquean el lock)
16. Consumidor real de cada pieza (r28) y doc
17. Qué fija el lock y qué queda reversible
18. Consecuencias