La máquina de estados de MediaPlayer: reproducir audio en Android sin errores
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
- Estados válidos de cada método:
setDataSourceenIdle(pasa aInitialized, posición 0);prepareenInitializedoStopped(pasa aPrepared, posición 0);startenPrepared,Started,PausedoPlaybackCompleted(pasa aStarted; desdePlaybackCompleted, vuelve a la posición 0);pauseenStartedoPaused;stopenPrepared,Started,Paused,StoppedoPlaybackCompleted;seekToenPrepared,Started,PausedoPlaybackCompleted(sin cambiar de estado; la posición se limita a 0..duración);setLoopingen todos menosError;reseten todos (vuelve aIdle, sin fuente, posición 0 y sin bucle); yreleasesiempre (pasa aEnd). - En un estado no válido se lanza
EstadoIlegalcon el mensajemétodo() en estado X; si el método esstart,pause,stoposeekTo, además el reproductor pasa aErrory el mensaje termina en→ Error(si ya estaba enError, no se añade). EnEnd, cualquier método salvoreleaselanzael reproductor ya se ha liberado. avanzar N(segundos) solo avanza enStarted(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 aPlaybackCompleted(onCompletion() →y el estado).- El estado se describe como
Estado (m:ss / m:ss)con la posición y la duración, o soloEstadosi no hay fuente (métododescribir, ya escrito). Elmainya escribe cada orden, las llamadas y cuelgues (llamadapausa si se está reproduciendo;colgarreanuda 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
| Método | Desde | Hasta |
|---|---|---|
| setDataSource | Idle | Initialized |
| prepare | Initialized, Stopped | Prepared |
| start | Prepared, Paused, PlaybackCompleted (y Started) | Started |
| pause | Started (y Paused) | Paused |
| stop | Prepared, Started, Paused, PlaybackCompleted (y Stopped) | Stopped |
| fin de la canción sin bucle | Started | PlaybackCompleted |
| reset | cualquiera menos End | Idle |
| release | cualquiera | End |
| 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.
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.
Ejemplo
setDataSource intro.mp3 180 prepare start avanzar 60 pause avanzar 30 seekTo 170 start avanzar 15 start estado
> 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
Solución explicada
Ver la solución completa
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 estadoPreparingy un avisoonPrepared()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().