Apuntes DAM
Volver al inicio

La máquina de estados de MediaPlayer: reproducir audio en Android sin errores

Ejercicio de JavaMedioUnos 60 minutos

Programa la máquina de estados de MediaPlayer, el reproductor de Android: qué métodos se pueden llamar en cada estado, cuándo se lanza IllegalStateException y se pasa a Error, el final de la canción con y sin bucle, y la pausa automática al perder el foco de audio con una llamada.

  • MediaPlayer y su diagrama de estados
  • IllegalStateException
  • Enumeraciones
  • Parámetros variables (varargs)
  • Foco de audio (AudioFocus)
  • Máquinas de estados

Enunciado

Para reproducir audio o vídeo en Android, MediaPlayer funciona como una máquina de estados: después de crearlo está en Idle; con setDataSource pasa a Initialized; con prepare, a Prepared; con start, a Started, y así. Cada método solo se puede llamar en ciertos estados: start() en Idle lanza IllegalStateException y deja el reproductor en Error, del que solo se sale con reset(). Es el error más típico de las prácticas de multimedia.

Además, una aplicación de audio educada respeta el foco de audio: si entra una llamada (pérdida temporal del foco), pausa; y si la llamada acaba (vuelve el foco), sigue, pero solo si fue ella quien pausó. El main y los nombres de los estados ya están escritos: tú programas la clase Reproductor.

Qué tiene que hacer el programa

  1. Estados válidos de cada método: setDataSource en Idle (pasa a Initialized, posición 0); prepare en Initialized o Stopped (pasa a Prepared, posición 0); start en Prepared, Started, Paused o PlaybackCompleted (pasa a Started; desde PlaybackCompleted, vuelve a la posición 0); pause en Started o Paused; stop en Prepared, Started, Paused, Stopped o PlaybackCompleted; seekTo en Prepared, Started, Paused o PlaybackCompleted (sin cambiar de estado; la posición se limita a 0..duración); setLooping en todos menos Error; reset en todos (vuelve a Idle, sin fuente, posición 0 y sin bucle); y release siempre (pasa a End).
  2. En un estado no válido se lanza EstadoIlegal con el mensaje método() en estado X; si el método es start, pause, stop o seekTo, además el reproductor pasa a Error y el mensaje termina en → Error (si ya estaba en Error, no se añade). En End, cualquier método salvo release lanza el reproductor ya se ha liberado.
  3. avanzar N (segundos) solo avanza en Started (si no: No avanza: el reproductor está en X): si no llega al final, devuelve el estado; si llega, con bucle vuelve a empezar (posición % duración, y se añade , ha vuelto a empezar) y sin bucle se queda en la duración y pasa a PlaybackCompleted (onCompletion() → y el estado).
  4. El estado se describe como Estado (m:ss / m:ss) con la posición y la duración, o solo Estado si no hay fuente (método describir, ya escrito). El main ya escribe cada orden, las llamadas y cuelgues (llamada pausa si se está reproduciendo; colgar reanuda si fue la llamada quien pausó) y los errores de formato.

Entrada

Una orden por línea: setDataSource fichero segundos, prepare, start, pause, stop, seekTo s, avanzar s, setLooping true|false, reset, release, llamada, colgar o estado.

Datos de referencia

Transiciones principales
MétodoDesdeHasta
setDataSourceIdleInitialized
prepareInitialized, StoppedPrepared
startPrepared, Paused, PlaybackCompleted (y Started)Started
pauseStarted (y Paused)Paused
stopPrepared, Started, Paused, PlaybackCompleted (y Stopped)Stopped
fin de la canción sin bucleStartedPlaybackCompleted
resetcualquiera menos EndIdle
releasecualquieraEnd
start, pause, stop o seekTo en un estado no válido—Error

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Una canción de principio a fin

Entrada

setDataSource intro.mp3 180
prepare
start
avanzar 60
pause
avanzar 30
seekTo 170
start
avanzar 15
start
estado

Salida por consola

> setDataSource intro.mp3 180
Initialized (0:00 / 3:00)
> prepare
Prepared (0:00 / 3:00)
> start
Started (0:00 / 3:00)
> avanzar 60
Started (1:00 / 3:00)
> pause
Paused (1:00 / 3:00)
> avanzar 30
No avanza: el reproductor está en Paused
> seekTo 170
Paused (2:50 / 3:00)
> start
Started (2:50 / 3:00)
> avanzar 15
onCompletion() → PlaybackCompleted (3:00 / 3:00)
> start
Started (0:00 / 3:00)
> estado
Estado: Started · intro.mp3 · 0:00 / 3:00 · bucle: no

Errores de estado

Entrada

start
reset
setDataSource tema.mp3 95
setDataSource otra.mp3 10
start
prepare
reset
setDataSource tema.mp3 95
prepare
stop
start
prepare
reset
setDataSource tema.mp3 95
setLooping true
prepare
start
avanzar 100
release
start
reset

Salida por consola

> start
IllegalStateException: start() en estado Idle → Error
> reset
Idle
> setDataSource tema.mp3 95
Initialized (0:00 / 1:35)
> setDataSource otra.mp3 10
IllegalStateException: setDataSource() en estado Initialized
> start
IllegalStateException: start() en estado Initialized → Error
> prepare
IllegalStateException: prepare() en estado Error
> reset
Idle
> setDataSource tema.mp3 95
Initialized (0:00 / 1:35)
> prepare
Prepared (0:00 / 1:35)
> stop
Stopped (0:00 / 1:35)
> start
IllegalStateException: start() en estado Stopped → Error
> prepare
IllegalStateException: prepare() en estado Error
> reset
Idle
> setDataSource tema.mp3 95
Initialized (0:00 / 1:35)
> setLooping true
Bucle: sí
> prepare
Prepared (0:00 / 1:35)
> start
Started (0:00 / 1:35)
> avanzar 100
Started (0:05 / 1:35), ha vuelto a empezar
> release
End
> start
IllegalStateException: el reproductor ya se ha liberado
> reset
IllegalStateException: el reproductor ya se ha liberado

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Un método que lo comprueba todo

Todos los métodos empiezan igual: ¿estoy en un estado válido? Escríbelo una vez con parámetros variables (Estado... validos) y un indicador de si el error lleva a Error.

java
private void exigir(String metodo, boolean alError, Estado... validos) {
    for (Estado v : validos) if (estado == v) return;
    // si llega aquí, el estado no es válido
}
2. Primero comprobar, después cambiar

Si exigir lanza la excepción, el método no llega a cambiar nada: el estado solo cambia en la línea de después.

3. El tiempo

avanzar es lo único que mueve la posición sola. Compara con la duración: el bucle se resuelve con el resto de la división.

4. Prueba los caminos raros

start sin preparar, pause después de stop, cualquier cosa después de release: en una aplicación real, cada uno de esos casos es un cierre inesperado.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

☕JavaLa máquina de estados de MediaPlayer: reproducir audio en Android sin erroresMedio

Ejemplo

Entrada (lo que se escribe por teclado)
setDataSource intro.mp3 180
prepare
start
avanzar 60
pause
avanzar 30
seekTo 170
start
avanzar 15
start
estado
Salida esperada
> setDataSource intro.mp3 180
Initialized (0:00 / 3:00)
> prepare
Prepared (0:00 / 3:00)
> start
Started (0:00 / 3:00)
> avanzar 60
Started (1:00 / 3:00)
> pause
Paused (1:00 / 3:00)
> avanzar 30
No avanza: el reproductor está en Paused
> seekTo 170
Paused (2:50 / 3:00)
> start
Started (2:50 / 3:00)
> avanzar 15
onCompletion() → PlaybackCompleted (3:00 / 3:00)
> start
Started (0:00 / 3:00)
> estado
Estado: Started · intro.mp3 · 0:00 / 3:00 · bucle: no
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
java
1import java.util.Scanner;
2
3enum Estado { Idle, Initialized, Prepared, Started, Paused, Stopped, PlaybackCompleted, Error, End }
4
5class EstadoIlegal extends RuntimeException {
6    EstadoIlegal(String mensaje) { super(mensaje); }
7}
8
9/** Un reproductor con la máquina de estados de android.media.MediaPlayer. */
10class Reproductor {
11    Estado estado = Estado.Idle;
12    String fuente;
13    int duracion, posicion;
14    boolean bucle, pausadoPorFoco;
15
16    static String tiempo(int s) { return s / 60 + ":" + String.format("%02d", s % 60); }
17
18    String describir() { return fuente == null ? estado.name() : estado + " (" + tiempo(posicion) + " / " + tiempo(duracion) + ")"; }
19
20    /**
21     * Comprueba que el método se puede llamar en el estado actual. Si no, lanza EstadoIlegal y, si
22     * alError es true (start, pause, stop, seekTo), el reproductor pasa al estado Error.
23     */
24    private void exigir(String metodo, boolean alError, Estado... validos) {
25        if (estado == Estado.End) throw new EstadoIlegal("el reproductor ya se ha liberado");
26        for (Estado v : validos) if (estado == v) return;
27        String msg = metodo + "() en estado " + estado;
28        if (alError && estado != Estado.Error) {
29            estado = Estado.Error;
30            msg += " → Error";
31        }
32        throw new EstadoIlegal(msg);
33    }
34
35    void setDataSource(String f, int dur) {
36        exigir("setDataSource", false, Estado.Idle);
37        fuente = f;
38        duracion = dur;
39        posicion = 0;
40        estado = Estado.Initialized;
41    }
42
43    void prepare() {
44        exigir("prepare", false, Estado.Initialized, Estado.Stopped);
45        posicion = 0;
46        estado = Estado.Prepared;
47    }
48
49    void start() {
50        exigir("start", true, Estado.Prepared, Estado.Started, Estado.Paused, Estado.PlaybackCompleted);
51        if (estado == Estado.PlaybackCompleted) posicion = 0;      // vuelve a empezar desde el principio
52        estado = Estado.Started;
53    }
54
55    void pause() {
56        exigir("pause", true, Estado.Started, Estado.Paused);
57        estado = Estado.Paused;
58    }
59
60    void stop() {
61        exigir("stop", true, Estado.Prepared, Estado.Started, Estado.Paused, Estado.Stopped, Estado.PlaybackCompleted);
62        estado = Estado.Stopped;
63    }
64
65    void seekTo(int s) {
66        exigir("seekTo", true, Estado.Prepared, Estado.Started, Estado.Paused, Estado.PlaybackCompleted);
67        posicion = Math.max(0, Math.min(duracion, s));
68    }
69
70    void setLooping(boolean b) {
71        exigir("setLooping", false, Estado.Idle, Estado.Initialized, Estado.Prepared, Estado.Started, Estado.Paused, Estado.Stopped, Estado.PlaybackCompleted);
72        bucle = b;
73    }
74
75    void reset() {
76        exigir("reset", false, Estado.values());
77        estado = Estado.Idle;
78        fuente = null;
79        duracion = posicion = 0;
80        bucle = false;
81    }
82
83    void release() {
84        estado = Estado.End;
85        fuente = null;
86    }
87
88    /** Pasa el tiempo: solo avanza reproduciendo; al llegar al final, vuelve a empezar o se completa. */
89    String avanzar(int s) {
90        if (estado != Estado.Started) return "No avanza: el reproductor está en " + estado;
91        posicion += s;
92        if (posicion < duracion) return describir();
93        if (bucle) {
94            posicion %= duracion;
95            return describir() + ", ha vuelto a empezar";
96        }
97        posicion = duracion;
98        estado = Estado.PlaybackCompleted;
99        return "onCompletion() → " + describir();
100    }
101}
102
103public class Main {
104    public static void main(String[] args) {
105        Scanner sc = new Scanner(System.in);
106        Reproductor r = new Reproductor();
107        while (sc.hasNextLine()) {
108            String linea = sc.nextLine().trim();
109            if (linea.isEmpty()) continue;
110            System.out.println("> " + linea);
111            String[] p = linea.split("\\s+");
112            try {
113                switch (p[0]) {
114                    case "setDataSource" -> {
115                        if (p.length != 3 || !p[2].matches("[1-9]\\d*")) throw new IllegalArgumentException();
116                        r.setDataSource(p[1], Integer.parseInt(p[2]));
117                        System.out.println(r.describir());
118                    }
119                    case "prepare", "start", "pause", "stop", "reset", "release" -> {
120                        if (p.length != 1) throw new IllegalArgumentException();
121                        r.pausadoPorFoco = false;          // una orden del usuario manda sobre el foco de audio
122                        switch (p[0]) {
123                            case "prepare" -> r.prepare();
124                            case "start" -> r.start();
125                            case "pause" -> r.pause();
126                            case "stop" -> r.stop();
127                            case "reset" -> r.reset();
128                            default -> r.release();
129                        }
130                        System.out.println(r.describir());
131                    }
132                    case "seekTo", "avanzar" -> {
133                        if (p.length != 2 || !p[1].matches("\\d+")) throw new IllegalArgumentException();
134                        int n = Integer.parseInt(p[1]);
135                        if (p[0].equals("avanzar")) System.out.println(r.avanzar(n));
136                        else {
137                            r.seekTo(n);
138                            System.out.println(r.describir());
139                        }
140                    }
141                    case "setLooping" -> {
142                        if (p.length != 2 || !p[1].matches("true|false")) throw new IllegalArgumentException();
143                        r.setLooping(Boolean.parseBoolean(p[1]));
144                        System.out.println("Bucle: " + (r.bucle ? "sí" : "no"));
145                    }
146                    case "llamada" -> {
147                        if (r.estado == Estado.Started) {
148                            r.pause();
149                            r.pausadoPorFoco = true;
150                            System.out.println("AUDIOFOCUS_LOSS_TRANSIENT: pause() → " + r.describir());
151                        } else System.out.println("AUDIOFOCUS_LOSS_TRANSIENT: no hay nada que pausar");
152                    }
153                    case "colgar" -> {
154                        if (r.pausadoPorFoco && r.estado == Estado.Paused) {
155                            r.start();
156                            System.out.println("AUDIOFOCUS_GAIN: start() → " + r.describir());
157                        } else System.out.println("AUDIOFOCUS_GAIN: no hay nada que reanudar");
158                        r.pausadoPorFoco = false;
159                    }
160                    case "estado" -> System.out.println("Estado: " + r.estado + (r.fuente == null ? "" : " · " + r.fuente + " · "
161                            + Reproductor.tiempo(r.posicion) + " / " + Reproductor.tiempo(r.duracion)) + " · bucle: " + (r.bucle ? "sí" : "no"));
162                    default -> System.out.println("Orden desconocida: " + p[0]);
163                }
164            } catch (EstadoIlegal e) {
165                System.out.println("IllegalStateException: " + e.getMessage());
166            } catch (IllegalArgumentException e) {
167                System.out.println("Orden no válida: " + linea);
168            }
169        }
170    }
171}

La máquina de estados existe porque preparar un medio es caro (abrir el fichero o la conexión, leer las cabeceras, reservar el decodificador): por eso prepare va aparte y, para medios de red, se usa prepareAsync con un aviso cuando termina.

release() libera el decodificador, que es un recurso escaso del sistema: una aplicación que no lo llama en onStop o onDestroy puede dejar sin audio a las demás. Después, el objeto ya no sirve para nada.

El foco de audio es un acuerdo entre aplicaciones: solo una suena a la vez. Ante una pérdida temporal (una llamada, un aviso de navegación) se pausa o se baja el volumen; ante una pérdida definitiva (otra app de música), se para.

Hoy muchas aplicaciones usan ExoPlayer (Media3), que tiene una máquina de estados más sencilla, pero las ideas son las mismas: estados, transiciones válidas y liberar los recursos.

Para ir más allá

  • Añade prepareAsync() con un estado Preparing y un aviso onPrepared() al avanzar el tiempo.
  • Añade una lista de reproducción que pase a la siguiente canción en onCompletion.
  • Distingue la pérdida temporal del foco de la definitiva (otra aplicación de música): en la definitiva, stop().

Dónde se explica