La máquina de un reproductor: siete situaciones, cero banderas
Un reproductor de vídeo es el caso de estudio canónico de una máquina de estados porque su comportamiento real no cabe en booleanos: reproduciendo, pausado y buffering no son tres valores de una misma variable sino tres situaciones con reglas distintas, y confundirlas produce la interfaz que todos hemos sufrido, con el botón que anuncia pausa mientras nada avanza y la rueda que gira sobre un vídeo detenido. Esta lección construye el grafo de siete estados, justifica cada arista, señala las que no deben existir y explica por qué el buffering es la frontera entre un modelo honesto y uno ingenuo.
El reproductor de medios es el ejemplo con el que casi todo el mundo descubre que su intuición sobre el estado era demasiado pobre. Empieza con una variable booleana llamada reproduciendo, gana otra llamada cargando cuando aparece el primer parpadeo, gana una tercera llamada buffering cuando alguien se queja de que la rueda no se distingue de la carga inicial, y termina con seis banderas que ningún ser humano puede razonar a la vez. El problema no es la cantidad de banderas sino su semántica: reproduciendo en falso no dice si el usuario pausó, si el búfer se vació, si el medio terminó o si la fuente no existe, y esas cuatro circunstancias exigen interfaces distintas, órdenes distintas y recuperaciones distintas. Un reproductor no tiene grados de una propiedad, tiene situaciones, y ese es exactamente el dominio donde una máquina de estados no es una elección estilística sino la descripción correcta del problema.
- Enumerar las siete situaciones de un reproductor y justificar por qué ninguna sobra.
- Distinguir carga inicial de buffering, y pausa deliberada de detención involuntaria.
- Declarar las aristas legítimas del grafo y reconocer las que deben quedar prohibidas.
- Derivar la interfaz completa desde el estado activo en lugar de desde combinaciones de banderas.
Las siete situaciones y por qué ninguna sobra
El catálogo mínimo de un reproductor honesto tiene siete posiciones. inactivo es antes de conocer la fuente; cargando es la primera adquisición de metadatos y del arranque del búfer; pausado es la detención deliberada del usuario con datos disponibles; reproduciendo es el único estado donde el tiempo del medio avanza; buffering es la detención involuntaria por falta de datos mientras la intención de reproducir sigue viva; terminado es haber llegado al final sin error; error es la imposibilidad de continuar. Nótese que dos de ellas —buffering y terminado— son las que casi siempre faltan en la primera versión casera, y son justo las que explican los peores fallos visibles.
import { setup, assign } from 'xstate'
export const reproductor = setup({
types: {
context: {} as { src: string; posicion: number; error: string | null },
events: {} as
| { type: 'CARGAR'; src: string }
| { type: 'PUEDE_REPRODUCIR' }
| { type: 'REPRODUCIR' }
| { type: 'PAUSAR' }
| { type: 'ESPERANDO_DATOS' }
| { type: 'DATOS_SUFICIENTES' }
| { type: 'FIN' }
| { type: 'FALLO'; motivo: string }
| { type: 'REINTENTAR' },
},
}).createMachine({
id: 'reproductor',
initial: 'inactivo',
context: { src: '', posicion: 0, error: null },
on: {
FALLO: {
target: '.error',
actions: assign({ error: ({ event }) => event.motivo }),
},
},
states: {
inactivo: {
on: {
CARGAR: { target: 'cargando', actions: assign({ src: ({ event }) => event.src }) },
},
},
cargando: {
on: { PUEDE_REPRODUCIR: 'pausado' },
},
pausado: {
on: { REPRODUCIR: 'reproduciendo', CARGAR: 'cargando' },
},
reproduciendo: {
on: { PAUSAR: 'pausado', ESPERANDO_DATOS: 'buffering', FIN: 'terminado' },
},
buffering: {
on: { DATOS_SUFICIENTES: 'reproduciendo', PAUSAR: 'pausado' },
},
terminado: {
on: {
REPRODUCIR: { target: 'reproduciendo', actions: assign({ posicion: 0 }) },
CARGAR: 'cargando',
},
},
error: {
on: { REINTENTAR: 'cargando' },
},
},
})
La transición de FALLO está declarada en la raíz y no en cada estado, porque un fallo del medio puede llegar en cualquier momento y repetir la misma arista siete veces sería exactamente el tipo de duplicación que la jerarquía existe para evitar. El destino .error con punto inicial es una referencia relativa al hijo del propio nodo raíz.
La diferencia entre cargando y buffering no es de grado sino de intención. En cargando el usuario aún no ha pedido reproducir y el sistema prepara el terreno; al terminar, lo correcto es quedarse en pausado esperando la orden. En buffering el usuario ya pidió reproducir, la intención sigue vigente y la detención es una interrupción que el sistema debe deshacer por su cuenta; al recuperar datos, lo correcto es volver a reproduciendo sin preguntar nada. Fundirlas en un solo estado obliga a inventar una bandera que recuerde la intención, y esa bandera es precisamente el estado que acabas de negarte a declarar.
Las aristas que existen y las que están prohibidas
Un grafo se define tanto por lo que conecta como por lo que se niega a conectar. Las ausencias del ejemplo son deliberadas y cada una elimina una clase entera de defectos que en la versión con banderas se manifiestan como comportamientos imposibles perfectamente alcanzables.
| Arista | ¿Existe? | Motivo |
|---|---|---|
inactivo hacia reproduciendo |
no | no hay datos todavía; la orden se perdería en el vacío |
cargando hacia reproduciendo |
no | la carga termina en pausado, y reproducir es una decisión aparte |
pausado hacia buffering |
no | sin intención de reproducir no hay interrupción que sufrir |
reproduciendo hacia buffering |
sí | el búfer se vació con la intención viva |
buffering hacia pausado |
sí | el usuario puede rendirse durante la espera |
terminado hacia buffering |
no | nada avanza, luego nada puede quedarse sin datos |
cualquiera hacia error |
sí | declarada una sola vez en la raíz |
stateDiagram-v2 [*] --> inactivo inactivo --> cargando: CARGAR cargando --> pausado: PUEDE_REPRODUCIR pausado --> reproduciendo: REPRODUCIR reproduciendo --> pausado: PAUSAR reproduciendo --> buffering: ESPERANDO_DATOS buffering --> reproduciendo: DATOS_SUFICIENTES buffering --> pausado: PAUSAR reproduciendo --> terminado: FIN terminado --> reproduciendo: REPRODUCIR error --> cargando: REINTENTAR
La tentación de escribir un único evento ALTERNAR que sirva para reproducir y pausar es fuerte y es un error. Ese evento no dice qué quiere el usuario, dice que ha tocado algo, y obliga a la máquina a adivinar la intención mirando dónde está. El síntoma llega con el doble clic rápido y con la orden que cruza un cambio de estado: dos pulsaciones seguidas durante buffering acaban en un sitio distinto según cuándo llegue el búfer. Un evento debe nombrar la intención, no el gesto. Declara REPRODUCIR y PAUSAR por separado y deja que cada estado ignore el que no le corresponde; la máquina se vuelve idempotente frente a órdenes repetidas sin escribir una sola guarda.
La interfaz se deriva, no se calcula
El beneficio inmediato aparece en la capa de presentación. Con banderas, cada elemento visual necesita una expresión booleana propia que combine varias de ellas, y esas expresiones divergen con el tiempo hasta contradecirse. Con un estado activo, la interfaz es una función total definida sobre siete casos, exhaustiva por construcción y verificable de un vistazo.
Un caso por situación
El renderizado se escribe como una correspondencia entre nombre de estado y vista. Si añades un estado, el compilador te obliga a decidir qué se ve en él.
Controles habilitados
Qué botón está activo se lee del grafo: si un estado no declara una transición para un evento, el botón que lo envía va deshabilitado.
Indicadores honestos
La rueda de espera aparece en cargando y en buffering, con textos distintos, y desaparece del resto sin excepciones que recordar.
Recuperación explícita
error y terminado ofrecen cada uno su acción de salida, en lugar de reciclar el mismo botón con significados diferentes.
Lo que de verdad enseña el reproductor es que un sistema interactivo tiene dos capas de realidad superpuestas y que solo una de ellas es observable desde fuera. La capa observable es la física del medio: si hay bytes en el búfer, si el reloj avanza, si el decodificador tiene trabajo. La capa invisible es la intención del usuario: si quiere que esto suene o no. Un modelo ingenuo se limita a la primera y se queda sin lenguaje para la segunda, y entonces necesita banderas para recordar lo que nunca se molestó en nombrar. Fíjate en la simetría: pausado y buffering son idénticos en la capa física —nada avanza en ninguno de los dos— y opuestos en la capa de intención, porque en uno el sistema está obedeciendo y en el otro está fallando. Ningún conjunto de mediciones del elemento de vídeo puede distinguirlos, porque la diferencia no está en el medio sino en el compromiso que el sistema adquirió con la persona. Por eso el estado no es un resumen de las variables: es la memoria de lo que se ha prometido. Una vez ves esto, el criterio para declarar un estado deja de ser cuántas variables ahorra y pasa a ser qué compromiso representa, y el grafo empieza a describir el contrato con el usuario en lugar de la configuración interna del programa. Ese desplazamiento —de describir cómo está la máquina a describir qué debe a quién— es el que convierte un statechart en documentación del dominio y no en un diagrama del código.
- Implementa la máquina completa y comprueba que enviar
REPRODUCIReninactivono produce ningún cambio ni ningún error. - Escribe la versión con banderas booleanas equivalente y enumera cuántas combinaciones son inalcanzables en la práctica pero representables en el tipo.
- Simula el vaciado del búfer durante la reproducción y verifica que al recuperar datos vuelves a
reproduciendosin intervención del usuario. - Pausa durante
bufferingy comprueba que la recuperación posterior de datos ya no reanuda nada, porque la intención fue revocada. - Sustituye
REPRODUCIRyPAUSARpor un únicoALTERNARy describe con precisión el defecto que aparece con dos pulsaciones rápidas durante la espera. - Deriva la tabla completa de la interfaz —texto del botón, visibilidad de la rueda, controles activos— con un caso por estado y ninguna condición compuesta.