Tema 7. Multimedia: Cámara, Galería, Audio y Vídeo#

  • Bloque: B4 — Multimedia en Android
  • Duración aproximada: 9 horas
  • RA3 — Desarrolla programas que integran contenidos multimedia analizando y empleando las tecnologías y librerías específicas.
Código Criterio
RA3-a Se han utilizado las herramientas y entornos de desarrollo para aplicaciones multimedia.
RA3-b Se han analizado las herramientas disponibles para el tratamiento de imágenes y vídeo.
RA3-c Se han incorporado procedimientos para la captura de imágenes y grabación de vídeo.
RA3-d Se han utilizado clases para la reproducción de audio y vídeo.
RA3-e Se han gestionado los permisos de acceso al hardware multimedia del dispositivo.

Dependencias necesarias#

 1// build.gradle.kts (módulo app)
 2dependencies {
 3    // ── CameraX ───────────────────────────────────────────────────────────────
 4    val cameraxVersion = "1.4.2"
 5    implementation("androidx.camera:camera-core:$cameraxVersion")
 6    implementation("androidx.camera:camera-camera2:$cameraxVersion")    // backend Camera2
 7    implementation("androidx.camera:camera-lifecycle:$cameraxVersion")  // integración con Lifecycle
 8    implementation("androidx.camera:camera-view:$cameraxVersion")       // PreviewView
 9    implementation("androidx.camera:camera-extensions:$cameraxVersion") // HDR, Bokeh, etc. (opcional)
10
11    // ── Media3 / ExoPlayer (reproducción de audio y vídeo) ───────────────────
12    val media3Version = "1.5.1"
13    implementation("androidx.media3:media3-exoplayer:$media3Version")
14    implementation("androidx.media3:media3-ui:$media3Version")          // PlayerView
15    implementation("androidx.media3:media3-common:$media3Version")
16
17    // ── Coil (ya incluido en B3, necesario para mostrar imágenes de la galería) ──
18    // io.coil-kt.coil3:coil-compose:3.2.0
19}

Permisos en AndroidManifest.xml:

 1<!-- Permiso de cámara — siempre necesario para CameraX -->
 2<uses-permission android:name="android.permission.CAMERA" />
 3
 4<!-- Acceso a imágenes en galería — API 33+ usa READ_MEDIA_IMAGES -->
 5<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"
 6    android:maxSdkVersion="32" />  <!-- ← Solo se solicita en API <= 32 -->
 7
 8<!-- API 32 e inferiores: READ_EXTERNAL_STORAGE -->
 9<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
10    android:maxSdkVersion="32" />
11
12<!-- Grabar audio junto con vídeo -->
13<uses-permission android:name="android.permission.RECORD_AUDIO" />
14
15<!-- Escribir en almacenamiento compartido — solo necesario en API <= 28 -->
16<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
17    android:maxSdkVersion="28" />
18
19<!-- Declarar el uso de hardware de cámara (el dispositivo no es obligatorio si no es required) -->
20<uses-feature android:name="android.hardware.camera" android:required="false" />

Nota sobre PickVisualMedia: el selector de medios del sistema (ActivityResultContracts.PickVisualMedia) no requiere ningún permiso de almacenamiento en Android 13+. Es la forma recomendada de acceder a la galería porque Google Photo Picker gestiona el acceso sin que la app necesite permisos.


1. Gestión de permisos en tiempo de ejecución#

Android 6.0 (API 23) introdujo los permisos en tiempo de ejecución: los permisos considerados peligrosos deben solicitarse explícitamente al usuario cuando la app los necesita. En Compose se usa ActivityResultContracts para lanzar el diálogo de permisos y reaccionar a la respuesta.

Patrón completo de solicitud de permiso en Compose#

 1// ui/components/SolicitarPermiso.kt
 2@Composable
 3fun PantallaConPermiso(
 4    permiso: String,
 5    textoExplicacion: String,
 6    contenido: @Composable () -> Unit
 7) {
 8    // Estado del permiso: GRANTED, DENIED, SHOW_RATIONALE
 9    var estadoPermiso by remember { mutableStateOf<Boolean?>(null) }
10
11    // Launcher que abre el diálogo de permiso del sistema
12    val launcher = rememberLauncherForActivityResult(
13        contract = ActivityResultContracts.RequestPermission()
14    ) { concedido ->
15        estadoPermiso = concedido
16    }
17
18    // Comprobar si el permiso ya está concedido al entrar en la pantalla
19    val context = LocalContext.current
20    LaunchedEffect(permiso) {
21        estadoPermiso = ContextCompat.checkSelfPermission(
22            context, permiso
23        ) == PackageManager.PERMISSION_GRANTED
24    }
25
26    when (estadoPermiso) {
27        true -> contenido()   // permiso concedido → mostrar el contenido real
28
29        false -> {            // permiso denegado → mostrar explicación y botón
30            Column(
31                modifier = Modifier.fillMaxSize().padding(32.dp),
32                horizontalAlignment = Alignment.CenterHorizontally,
33                verticalArrangement = Arrangement.Center
34            ) {
35                Icon(Icons.Default.Lock, contentDescription = null,
36                    modifier = Modifier.size(64.dp),
37                    tint = MaterialTheme.colorScheme.onSurfaceVariant)
38                Spacer(Modifier.height(16.dp))
39                Text(textoExplicacion,
40                    style = MaterialTheme.typography.bodyLarge,
41                    textAlign = TextAlign.Center)
42                Spacer(Modifier.height(24.dp))
43                Button(onClick = { launcher.launch(permiso) }) {
44                    Text("Conceder permiso")
45                }
46            }
47        }
48
49        null -> {             // estado desconocido → solicitar inmediatamente
50            LaunchedEffect(Unit) { launcher.launch(permiso) }
51        }
52    }
53}
54
55// Solicitar múltiples permisos a la vez
56val launcherMultiple = rememberLauncherForActivityResult(
57    contract = ActivityResultContracts.RequestMultiplePermissions()
58) { resultados ->
59    val camaraOk = resultados[Manifest.permission.CAMERA] == true
60    val audioOk  = resultados[Manifest.permission.RECORD_AUDIO] == true
61    // reaccionar según los resultados
62}
63
64// Lanzar solicitud de varios permisos
65launcherMultiple.launch(arrayOf(
66    Manifest.permission.CAMERA,
67    Manifest.permission.RECORD_AUDIO
68))

2. CameraX: captura de fotos#

CameraX es la biblioteca de cámara recomendada por Google para Android. Abstrae las diferencias entre dispositivos y versiones de Android, y se integra nativamente con el ciclo de vida de los componentes Jetpack.

Arquitectura de CameraX#

CameraX se organiza en casos de uso que se pueden combinar:

Caso de uso Clase Para qué sirve
Vista previa Preview Mostrar lo que ve la cámara en pantalla
Captura de imagen ImageCapture Tomar fotografías
Análisis de imagen ImageAnalysis Procesar frames en tiempo real (QR, ML)
Captura de vídeo VideoCapture<Recorder> Grabar vídeo

Se pueden combinar hasta tres casos de uso simultáneamente en la mayoría de dispositivos.

PreviewView en Compose con AndroidView#

PreviewView es una vista Android clásica (no un Composable). Para usarla en Compose se envuelve con AndroidView:

  1// ui/camara/PantallaCamara.kt
  2@Composable
  3fun PantallaCamara(
  4    onFotoCapturada: (Uri) -> Unit,
  5    onVolver: () -> Unit
  6) {
  7    PantallaConPermiso(
  8        permiso = Manifest.permission.CAMERA,
  9        textoExplicacion = "La cámara es necesaria para tomar fotos en AppFlix."
 10    ) {
 11        ContenidoCamara(
 12            onFotoCapturada = onFotoCapturada,
 13            onVolver = onVolver
 14        )
 15    }
 16}
 17
 18@OptIn(ExperimentalGetImage::class)
 19@Composable
 20private fun ContenidoCamara(
 21    onFotoCapturada: (Uri) -> Unit,
 22    onVolver: () -> Unit
 23) {
 24    val context = LocalContext.current
 25    val lifecycleOwner = LocalLifecycleOwner.current
 26
 27    // Casos de uso de CameraX
 28    val preview = remember { Preview.Builder().build() }
 29    val imageCapture = remember {
 30        ImageCapture.Builder()
 31            .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
 32            .build()
 33    }
 34
 35    // ProcessCameraProvider: singleton que gestiona las conexiones a la cámara
 36    val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
 37    var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) }
 38    var estaCaptruando by remember { mutableStateOf(false) }
 39
 40    // Selector de la cámara trasera por defecto
 41    val selectorCamara = CameraSelector.DEFAULT_BACK_CAMERA
 42
 43    Scaffold(
 44        topBar = {
 45            TopAppBar(
 46                title = { Text("Cámara") },
 47                navigationIcon = {
 48                    IconButton(onClick = onVolver) {
 49                        Icon(Icons.AutoMirrored.Filled.ArrowBack, "Volver")
 50                    }
 51                }
 52            )
 53        }
 54    ) { padding ->
 55        Box(modifier = Modifier.fillMaxSize().padding(padding)) {
 56
 57            // AndroidView: integra PreviewView (View clásica) dentro de Compose
 58            AndroidView(
 59                factory = { ctx ->
 60                    val previewView = PreviewView(ctx).apply {
 61                        layoutParams = ViewGroup.LayoutParams(
 62                            ViewGroup.LayoutParams.MATCH_PARENT,
 63                            ViewGroup.LayoutParams.MATCH_PARENT
 64                        )
 65                        scaleType = PreviewView.ScaleType.FILL_CENTER
 66                        implementationMode = PreviewView.ImplementationMode.COMPATIBLE
 67                    }
 68
 69                    // Vincular los casos de uso al ciclo de vida cuando el provider esté listo
 70                    cameraProviderFuture.addListener({
 71                        cameraProvider = cameraProviderFuture.get()
 72
 73                        // Conectar el SurfaceProvider de PreviewView al caso de uso Preview
 74                        preview.surfaceProvider = previewView.surfaceProvider
 75
 76                        try {
 77                            // Desvincular cualquier uso previo antes de vincular los nuevos
 78                            cameraProvider?.unbindAll()
 79                            // bindToLifecycle: los casos de uso siguen el ciclo de vida
 80                            // del lifecycleOwner (la Activity/Fragment actual)
 81                            cameraProvider?.bindToLifecycle(
 82                                lifecycleOwner,
 83                                selectorCamara,
 84                                preview,
 85                                imageCapture
 86                            )
 87                        } catch (e: Exception) {
 88                            Log.e("CameraX", "Error al vincular la cámara", e)
 89                        }
 90                    }, ContextCompat.getMainExecutor(ctx))
 91
 92                    previewView
 93                },
 94                modifier = Modifier.fillMaxSize()
 95            )
 96
 97            // Botón de captura — centrado en la parte inferior
 98            Box(
 99                modifier = Modifier
100                    .align(Alignment.BottomCenter)
101                    .padding(bottom = 32.dp)
102            ) {
103                FilledIconButton(
104                    onClick = {
105                        if (!estaCaptruando) {
106                            estaCaptruando = true
107                            tomarFoto(
108                                imageCapture = imageCapture,
109                                context = context,
110                                onFotoCapturada = { uri ->
111                                    estaCaptruando = false
112                                    onFotoCapturada(uri)
113                                },
114                                onError = { estaCaptruando = false }
115                            )
116                        }
117                    },
118                    modifier = Modifier.size(72.dp)
119                ) {
120                    if (estaCaptruando) {
121                        CircularProgressIndicator(
122                            modifier = Modifier.size(32.dp),
123                            color = MaterialTheme.colorScheme.onPrimary,
124                            strokeWidth = 3.dp
125                        )
126                    } else {
127                        Icon(Icons.Default.Camera, "Capturar foto",
128                            modifier = Modifier.size(36.dp))
129                    }
130                }
131            }
132        }
133    }
134}

Guardar la foto en MediaStore#

MediaStore es la base de datos de medios de Android. Guardar en MediaStore hace que la foto sea visible en la galería del sistema:

 1// Función auxiliar: tomar foto y guardar en MediaStore
 2private fun tomarFoto(
 3    imageCapture: ImageCapture,
 4    context: Context,
 5    onFotoCapturada: (Uri) -> Unit,
 6    onError: () -> Unit
 7) {
 8    // Nombre de archivo único basado en la fecha y hora actual
 9    val nombre = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
10        .format(System.currentTimeMillis())
11
12    // ContentValues: metadatos del archivo que se creará en MediaStore
13    val contentValues = ContentValues().apply {
14        put(MediaStore.MediaColumns.DISPLAY_NAME, "APPFLIX_$nombre")
15        put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
16        // En API 29+ las imágenes van a Pictures/AppFlix en almacenamiento compartido
17        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
18            put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/AppFlix")
19        }
20    }
21
22    // OutputFileOptions: describe dónde y cómo guardar la foto
23    val outputOptions = ImageCapture.OutputFileOptions.Builder(
24        context.contentResolver,
25        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
26        contentValues
27    ).build()
28
29    // takePicture: captura asíncrona — el callback se llama cuando termina
30    imageCapture.takePicture(
31        outputOptions,
32        ContextCompat.getMainExecutor(context),
33        object : ImageCapture.OnImageSavedCallback {
34            override fun onImageSaved(output: ImageCapture.OutputFileResults) {
35                // output.savedUri: Uri de la foto guardada en MediaStore
36                output.savedUri?.let { onFotoCapturada(it) }
37            }
38
39            override fun onError(exception: ImageCaptureException) {
40                Log.e("CameraX", "Error al guardar la foto", exception)
41                onError()
42            }
43        }
44    )
45}

3. Selector de medios del sistema: PickVisualMedia#

ActivityResultContracts.PickVisualMedia abre el selector de medios del sistema (Google Photo Picker en Android 13+, o el selector del sistema en versiones anteriores con la librería de compatibilidad). No requiere permisos en Android 13+.

 1// ui/galeria/SelectorGaleria.kt
 2@Composable
 3fun SelectorDeGaleria(
 4    onImagenSeleccionada: (Uri) -> Unit
 5) {
 6    // Launcher para seleccionar UNA imagen
 7    val launcherImagen = rememberLauncherForActivityResult(
 8        contract = ActivityResultContracts.PickVisualMedia()
 9    ) { uri ->
10        // uri es null si el usuario cancela sin seleccionar nada
11        uri?.let { onImagenSeleccionada(it) }
12    }
13
14    // Launcher para seleccionar MÚLTIPLES imágenes (hasta 5)
15    val launcherMultiple = rememberLauncherForActivityResult(
16        contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 5)
17    ) { uris ->
18        uris.forEach { uri -> /* procesar cada Uri */ }
19    }
20
21    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
22        // Seleccionar solo imágenes
23        OutlinedButton(onClick = {
24            launcherImagen.launch(
25                PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
26            )
27        }) {
28            Icon(Icons.Default.Photo, contentDescription = null)
29            Spacer(Modifier.width(8.dp))
30            Text("Seleccionar imagen")
31        }
32
33        // Seleccionar solo vídeos
34        OutlinedButton(onClick = {
35            launcherImagen.launch(
36                PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)
37            )
38        }) {
39            Icon(Icons.Default.Videocam, contentDescription = null)
40            Spacer(Modifier.width(8.dp))
41            Text("Seleccionar vídeo")
42        }
43
44        // Seleccionar imágenes y vídeos
45        OutlinedButton(onClick = {
46            launcherMultiple.launch(
47                PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
48            )
49        }) {
50            Icon(Icons.Default.PermMedia, contentDescription = null)
51            Spacer(Modifier.width(8.dp))
52            Text("Seleccionar hasta 5 archivos")
53        }
54    }
55}
56
57// Mostrar la imagen seleccionada con Coil
58@Composable
59fun ImagenSeleccionada(uri: Uri?) {
60    if (uri != null) {
61        AsyncImage(
62            model = ImageRequest.Builder(LocalContext.current)
63                .data(uri)
64                .crossfade(true)
65                .build(),
66            contentDescription = "Imagen seleccionada",
67            contentScale = ContentScale.Crop,
68            modifier = Modifier
69                .fillMaxWidth()
70                .aspectRatio(1f)
71                .clip(RoundedCornerShape(12.dp))
72        )
73    } else {
74        Box(
75            modifier = Modifier
76                .fillMaxWidth()
77                .aspectRatio(1f)
78                .clip(RoundedCornerShape(12.dp))
79                .background(MaterialTheme.colorScheme.surfaceVariant),
80            contentAlignment = Alignment.Center
81        ) {
82            Column(horizontalAlignment = Alignment.CenterHorizontally) {
83                Icon(Icons.Default.AddPhotoAlternate, contentDescription = null,
84                    modifier = Modifier.size(48.dp),
85                    tint = MaterialTheme.colorScheme.onSurfaceVariant)
86                Text("Selecciona una imagen",
87                    style = MaterialTheme.typography.bodySmall,
88                    color = MaterialTheme.colorScheme.onSurfaceVariant)
89            }
90        }
91    }
92}

Comparativa: PickVisualMedia vs ACTION_GET_CONTENT#

Aspecto PickVisualMedia (moderno ✅) ACTION_GET_CONTENT (legacy)
Disponible desde Android 11 (API 30) con compat Android 1.0
Permisos necesarios Ninguno (API 33+) READ_EXTERNAL_STORAGE
Privacidad Solo ve los archivos que el usuario selecciona Acceso potencial a todo el almacenamiento
UI Google Photo Picker o selector del sistema Explorador de archivos del sistema
Selección múltiple PickMultipleVisualMedia Intent.EXTRA_ALLOW_MULTIPLE = true
Recomendación Google ✅ Usar siempre en apps nuevas Solo para casos legacy

4. Audio con MediaPlayer#

MediaPlayer es la API clásica de Android para reproducir audio y vídeo de corta o mediana duración. Es adecuado para efectos de sonido, música de fondo y reproducción de archivos locales.

Ciclo de vida de MediaPlayer#

MediaPlayer es una máquina de estados. Llamar a un método en el estado incorrecto produce una IllegalStateException:

[Idle] ──setDataSource()──► [Initialized]
                            prepare() / prepareAsync()
                             [Prepared] ──────────────────────────────┐
                                  │                                   │
                              start()                            seekTo()
                                  │                                   │
                            [Started] ──pause()──► [Paused] ──start()─┘
                              stop()
                             [Stopped] ──prepare()──► [Prepared]
                             reset() o release()
                    [Idle] o [End (liberado)]

MediaPlayer en ViewModel: gestión del ciclo de vida#

  1// ui/audio/AudioViewModel.kt
  2class AudioViewModel : ViewModel() {
  3
  4    // MediaPlayer es nullable: null cuando no está inicializado o ha sido liberado
  5    private var mediaPlayer: MediaPlayer? = null
  6
  7    private val _estaBreprodciendo = MutableStateFlow(false)
  8    val estaReproduciendo: StateFlow<Boolean> = _estaBreprodciendo.asStateFlow()
  9
 10    private val _progreso = MutableStateFlow(0f)     // 0.0 a 1.0
 11    val progreso: StateFlow<Float> = _progreso.asStateFlow()
 12
 13    private val _duracion = MutableStateFlow(0)      // milisegundos
 14    val duracion: StateFlow<Int> = _duracion.asStateFlow()
 15
 16    // Reproducir desde recursos locales (res/raw/nombre_archivo.mp3)
 17    fun reproducirDesdeRecurso(context: Context, @RawRes recursoId: Int) {
 18        liberarPlayer()   // liberar el anterior si existía
 19
 20        mediaPlayer = MediaPlayer.create(context, recursoId).apply {
 21            // setOnCompletionListener: se llama cuando termina la reproducción
 22            setOnCompletionListener {
 23                _estaBreprodciendo.value = false
 24                _progreso.value = 0f
 25            }
 26            start()
 27            _estaBreprodciendo.value = true
 28            _duracion.value = duration
 29        }
 30
 31        // Actualizar el progreso cada 500ms con una corrutina
 32        viewModelScope.launch {
 33            while (mediaPlayer?.isPlaying == true) {
 34                val posicion = mediaPlayer?.currentPosition ?: 0
 35                val total = mediaPlayer?.duration?.takeIf { it > 0 } ?: 1
 36                _progreso.value = posicion.toFloat() / total
 37                delay(500)
 38            }
 39        }
 40    }
 41
 42    // Reproducir desde URL (streaming)
 43    fun reproducirDesdeUrl(url: String) {
 44        liberarPlayer()
 45
 46        mediaPlayer = MediaPlayer().apply {
 47            setDataSource(url)
 48            setOnPreparedListener { mp ->
 49                mp.start()
 50                _estaBreprodciendo.value = true
 51                _duracion.value = mp.duration
 52            }
 53            setOnErrorListener { _, what, extra ->
 54                Log.e("MediaPlayer", "Error: what=$what extra=$extra")
 55                _estaBreprodciendo.value = false
 56                true   // true indica que el error ha sido gestionado
 57            }
 58            // prepareAsync(): no bloquea el hilo principal
 59            // Llama a OnPreparedListener cuando está listo
 60            prepareAsync()
 61        }
 62    }
 63
 64    fun pausarOReanudar() {
 65        mediaPlayer?.let { mp ->
 66            if (mp.isPlaying) {
 67                mp.pause()
 68                _estaBreprodciendo.value = false
 69            } else {
 70                mp.start()
 71                _estaBreprodciendo.value = true
 72            }
 73        }
 74    }
 75
 76    fun buscarPosicion(progreso: Float) {
 77        mediaPlayer?.let { mp ->
 78            val posicion = (progreso * mp.duration).toInt()
 79            mp.seekTo(posicion)
 80        }
 81    }
 82
 83    private fun liberarPlayer() {
 84        mediaPlayer?.apply {
 85            if (isPlaying) stop()
 86            reset()
 87            release()
 88        }
 89        mediaPlayer = null
 90        _estaBreprodciendo.value = false
 91        _progreso.value = 0f
 92    }
 93
 94    // CRÍTICO: liberar MediaPlayer cuando el ViewModel se destruye
 95    // Sin esto se producen fugas de recursos (memory leak)
 96    override fun onCleared() {
 97        super.onCleared()
 98        liberarPlayer()
 99    }
100}

Composable de reproductor de audio#

 1@Composable
 2fun ReproductorAudio(
 3    viewModel: AudioViewModel = viewModel()
 4) {
 5    val estaReproduciendo by viewModel.estaReproduciendo.collectAsStateWithLifecycle()
 6    val progreso by viewModel.progreso.collectAsStateWithLifecycle()
 7    val duracion by viewModel.duracion.collectAsStateWithLifecycle()
 8    val context = LocalContext.current
 9
10    // Formato milisegundos → mm:ss
11    fun Int.formatearTiempo(): String {
12        val totalSegundos = this / 1000
13        val minutos = totalSegundos / 60
14        val segundos = totalSegundos % 60
15        return "%d:%02d".format(minutos, segundos)
16    }
17
18    Column(
19        modifier = Modifier
20            .fillMaxWidth()
21            .padding(16.dp),
22        horizontalAlignment = Alignment.CenterHorizontally
23    ) {
24        Icon(Icons.Default.MusicNote, contentDescription = null,
25            modifier = Modifier.size(80.dp),
26            tint = MaterialTheme.colorScheme.primary)
27
28        Spacer(Modifier.height(16.dp))
29
30        // Barra de progreso interactiva
31        Slider(
32            value = progreso,
33            onValueChange = { viewModel.buscarPosicion(it) },
34            modifier = Modifier.fillMaxWidth()
35        )
36
37        // Tiempo actual / duración total
38        Row(
39            modifier = Modifier.fillMaxWidth(),
40            horizontalArrangement = Arrangement.SpaceBetween
41        ) {
42            Text((progreso * duracion).toInt().formatearTiempo(),
43                style = MaterialTheme.typography.labelSmall)
44            Text(duracion.formatearTiempo(),
45                style = MaterialTheme.typography.labelSmall)
46        }
47
48        Spacer(Modifier.height(8.dp))
49
50        // Botón de reproducir/pausar
51        FilledIconButton(
52            onClick = {
53                if (!estaReproduciendo) {
54                    // En un caso real, el recurso o URL vendría del ViewModel o navegación
55                    viewModel.reproducirDesdeRecurso(context, R.raw.muestra_audio)
56                } else {
57                    viewModel.pausarOReanudar()
58                }
59            },
60            modifier = Modifier.size(64.dp)
61        ) {
62            Icon(
63                imageVector = if (estaReproduciendo) Icons.Default.Pause else Icons.Default.PlayArrow,
64                contentDescription = if (estaReproduciendo) "Pausar" else "Reproducir",
65                modifier = Modifier.size(32.dp)
66            )
67        }
68    }
69}

5. Vídeo con Media3/ExoPlayer#

Media3 es la biblioteca de medios moderna de Google, que unifica ExoPlayer, MediaSession y otras APIs bajo un mismo paraguas. Para reproducción de vídeo es la opción recomendada sobre VideoView o MediaPlayer.

ExoPlayer vs MediaPlayer#

Aspecto MediaPlayer ExoPlayer (Media3)
Formatos soportados MP4, MP3, AAC, WAV, OGG… Todo lo anterior + DASH, HLS, SmoothStreaming
Streaming adaptativo No Sí (DASH, HLS)
Complejidad API Simple Media (más configuración)
DRM (contenido protegido) Limitado Sí (Widevine, ClearKey)
Cuándo usar Archivos locales simples, efectos Streaming, formatos avanzados, vídeo en lista

ExoPlayer en ViewModel y Compose#

 1// ui/video/VideoViewModel.kt
 2class VideoViewModel(private val application: Application) : AndroidViewModel(application) {
 3
 4    // ExoPlayer necesita un Context — se usa AndroidViewModel para acceder a application
 5    val player: ExoPlayer = ExoPlayer.Builder(application)
 6        .build()
 7        .apply {
 8            // repeatMode: ONE = repetir la pista actual, OFF = no repetir
 9            repeatMode = ExoPlayer.REPEAT_MODE_OFF
10            playWhenReady = true   // empezar a reproducir cuando el buffer esté listo
11        }
12
13    fun cargarUrl(url: String) {
14        val mediaItem = MediaItem.fromUri(url)
15        player.setMediaItem(mediaItem)
16        player.prepare()   // iniciar la carga del contenido
17    }
18
19    fun cargarUri(uri: Uri) {
20        val mediaItem = MediaItem.fromUri(uri)
21        player.setMediaItem(mediaItem)
22        player.prepare()
23    }
24
25    override fun onCleared() {
26        super.onCleared()
27        player.release()   // CRÍTICO: liberar el player al destruir el ViewModel
28    }
29
30    companion object {
31        val Factory: ViewModelProvider.Factory = viewModelFactory {
32            initializer {
33                val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
34                    as Application
35                VideoViewModel(app)
36            }
37        }
38    }
39}
40
41// ui/video/ReproductorVideo.kt
42@Composable
43fun ReproductorVideo(
44    urlVideo: String,
45    viewModel: VideoViewModel = viewModel(factory = VideoViewModel.Factory)
46) {
47    val lifecycleOwner = LocalLifecycleOwner.current
48
49    // Cargar el vídeo cuando cambia la URL
50    LaunchedEffect(urlVideo) {
51        viewModel.cargarUrl(urlVideo)
52    }
53
54    // DisposableEffect: ejecuta código al montar y al desmontar el Composable
55    // Se usa para pausar/reanudar ExoPlayer según el ciclo de vida de la Activity
56    DisposableEffect(lifecycleOwner) {
57        val observador = LifecycleEventObserver { _, evento ->
58            when (evento) {
59                Lifecycle.Event.ON_PAUSE  -> viewModel.player.pause()
60                Lifecycle.Event.ON_RESUME -> viewModel.player.play()
61                else -> {}
62            }
63        }
64        lifecycleOwner.lifecycle.addObserver(observador)
65
66        onDispose {
67            lifecycleOwner.lifecycle.removeObserver(observador)
68        }
69    }
70
71    // PlayerView: la vista de ExoPlayer — se integra en Compose con AndroidView
72    AndroidView(
73        factory = { ctx ->
74            PlayerView(ctx).apply {
75                player = viewModel.player
76                useController = true       // mostrar controles de reproducción
77                resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
78            }
79        },
80        update = { playerView ->
81            // update se llama en cada recomposición — actualizar el player si cambia
82            playerView.player = viewModel.player
83        },
84        modifier = Modifier
85            .fillMaxWidth()
86            .aspectRatio(16f / 9f)
87    )
88}

6. Grabación de vídeo con CameraX VideoCapture#

Para grabar vídeo con CameraX se usa el caso de uso VideoCapture<Recorder>. Requiere el permiso RECORD_AUDIO además del de cámara:

  1// Caso de uso de grabación de vídeo
  2@Composable
  3fun PantallaGrabacionVideo() {
  4    val context = LocalContext.current
  5    val lifecycleOwner = LocalLifecycleOwner.current
  6
  7    val preview      = remember { Preview.Builder().build() }
  8    val recorder     = remember { Recorder.Builder()
  9        .setQualitySelector(QualitySelector.from(Quality.HD))
 10        .build()
 11    }
 12    val videoCapture = remember { VideoCapture.withOutput(recorder) }
 13
 14    var grabacionActiva by remember { mutableStateOf<Recording?>(null) }
 15    var estaGrabando   by remember { mutableStateOf(false) }
 16
 17    val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
 18
 19    Box(modifier = Modifier.fillMaxSize()) {
 20        AndroidView(
 21            factory = { ctx ->
 22                PreviewView(ctx).also { previewView ->
 23                    previewView.layoutParams = ViewGroup.LayoutParams(
 24                        ViewGroup.LayoutParams.MATCH_PARENT,
 25                        ViewGroup.LayoutParams.MATCH_PARENT
 26                    )
 27                    cameraProviderFuture.addListener({
 28                        val provider = cameraProviderFuture.get()
 29                        preview.surfaceProvider = previewView.surfaceProvider
 30                        provider.unbindAll()
 31                        provider.bindToLifecycle(
 32                            lifecycleOwner,
 33                            CameraSelector.DEFAULT_BACK_CAMERA,
 34                            preview,
 35                            videoCapture
 36                        )
 37                    }, ContextCompat.getMainExecutor(ctx))
 38                }
 39            },
 40            modifier = Modifier.fillMaxSize()
 41        )
 42
 43        // Botón de grabación
 44        FloatingActionButton(
 45            onClick = {
 46                if (estaGrabando) {
 47                    // Detener grabación
 48                    grabacionActiva?.stop()
 49                    grabacionActiva = null
 50                    estaGrabando = false
 51                } else {
 52                    // Iniciar grabación
 53                    val nombre = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
 54                        .format(System.currentTimeMillis())
 55                    val contentValues = ContentValues().apply {
 56                        put(MediaStore.Video.Media.DISPLAY_NAME, "APPFLIX_$nombre")
 57                        put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
 58                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
 59                            put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/AppFlix")
 60                        }
 61                    }
 62                    val mediaStoreOutput = MediaStoreOutputOptions.Builder(
 63                        context.contentResolver,
 64                        MediaStore.Video.Media.EXTERNAL_CONTENT_URI
 65                    ).setContentValues(contentValues).build()
 66
 67                    // Requiere permiso RECORD_AUDIO verificado previamente
 68                    grabacionActiva = videoCapture.output
 69                        .prepareRecording(context, mediaStoreOutput)
 70                        .withAudioEnabled()   // grabar audio con el vídeo
 71                        .start(ContextCompat.getMainExecutor(context)) { evento ->
 72                            when (evento) {
 73                                is VideoRecordEvent.Start  -> estaGrabando = true
 74                                is VideoRecordEvent.Finalize -> {
 75                                    estaGrabando = false
 76                                    if (!evento.hasError()) {
 77                                        Log.d("CameraX", "Vídeo guardado: ${evento.outputResults.outputUri}")
 78                                    }
 79                                }
 80                            }
 81                        }
 82                }
 83            },
 84            containerColor = if (estaGrabando) MaterialTheme.colorScheme.error
 85                             else MaterialTheme.colorScheme.primary,
 86            modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 32.dp)
 87        ) {
 88            Icon(
 89                imageVector = if (estaGrabando) Icons.Default.Stop else Icons.Default.Videocam,
 90                contentDescription = if (estaGrabando) "Detener" else "Grabar"
 91            )
 92        }
 93
 94        // Indicador de grabación activa
 95        if (estaGrabando) {
 96            Row(
 97                modifier = Modifier.align(Alignment.TopStart).padding(16.dp),
 98                verticalAlignment = Alignment.CenterVertically
 99            ) {
100                Box(modifier = Modifier
101                    .size(12.dp)
102                    .background(Color.Red, CircleShape))
103                Spacer(Modifier.width(8.dp))
104                Text("REC", color = Color.White,
105                    style = MaterialTheme.typography.labelLarge)
106            }
107        }
108    }
109}

7. Integración multimedia en AppFlix#

La funcionalidad multimedia se integra en AppFlix como extensión de la pantalla de detalle: el usuario puede añadir una foto personal a una película y reproducir un tráiler:

 1// Rutas adicionales para multimedia — añadir a Rutas.kt
 2@Serializable data object PantallaCamara
 3@Serializable data object PantallaGaleria
 4@Serializable data class PantallaTrailer(val url: String)
 5
 6// Extensión en la PantallaDetalle — botones de acción multimedia
 7@Composable
 8fun AccionesMultimediaDetalle(
 9    peliculaId: Int,
10    trailerUrl: String?,
11    onAbrirCamara: () -> Unit,
12    onAbrirGaleria: () -> Unit,
13    onVerTrailer: (String) -> Unit
14) {
15    Row(
16        horizontalArrangement = Arrangement.spacedBy(8.dp),
17        modifier = Modifier.padding(horizontal = 16.dp)
18    ) {
19        // Foto con la cámara
20        OutlinedButton(onClick = onAbrirCamara) {
21            Icon(Icons.Default.CameraAlt, contentDescription = null)
22            Spacer(Modifier.width(4.dp))
23            Text("Foto")
24        }
25
26        // Seleccionar de galería
27        OutlinedButton(onClick = onAbrirGaleria) {
28            Icon(Icons.Default.Photo, contentDescription = null)
29            Spacer(Modifier.width(4.dp))
30            Text("Galería")
31        }
32
33        // Ver tráiler si hay URL disponible
34        if (trailerUrl != null) {
35            Button(onClick = { onVerTrailer(trailerUrl) }) {
36                Icon(Icons.Default.PlayCircle, contentDescription = null)
37                Spacer(Modifier.width(4.dp))
38                Text("Tráiler")
39            }
40        }
41    }
42}

Estructura de paquetes al final de T7#

com.ejemplo.appflix/ui/
├── camara/
│   ├── PantallaCamara.kt        ← CameraX Preview + ImageCapture
│   └── PantallaGrabacion.kt     ← CameraX VideoCapture<Recorder>
├── galeria/
│   └── SelectorGaleria.kt       ← PickVisualMedia
├── audio/
│   ├── ReproductorAudio.kt      ← Composable del reproductor
│   └── AudioViewModel.kt        ← MediaPlayer en ViewModel
├── video/
│   ├── ReproductorVideo.kt      ← ExoPlayer/PlayerView con AndroidView
│   └── VideoViewModel.kt        ← ExoPlayer en AndroidViewModel
└── components/
    └── SolicitarPermiso.kt      ← Composable reutilizable de permisos

Actividades prácticas#

Actividad 7.1 — Cámara y galería en AppFlix (5 h) Añadir a la pantalla de detalle de AppFlix la opción de tomar una foto de la película con la cámara o seleccionarla desde la galería. La foto seleccionada debe mostrarse en la pantalla de detalle junto al póster de TMDB. Incluir la gestión correcta del permiso de cámara con el composable PantallaConPermiso.

Actividad 7.2 — Reproductor de audio (4 h) Añadir una sección de “Banda sonora” en la pantalla de detalle con un reproductor básico usando MediaPlayer. El audio puede ser un archivo local en res/raw/. Verificar que el MediaPlayer se libera correctamente al salir de la pantalla (implementar onCleared()).

Actividad 7.3 — Hito vertebrador H7 (5 h) AppFlix con multimedia completo: foto de portada personal por película (cámara o galería), reproductor de audio de la banda sonora, y reproductor de tráiler con ExoPlayer desde una URL de ejemplo. El vídeo debe pausarse automáticamente cuando la app va a segundo plano (DisposableEffect con Lifecycle).


Pruebas de evaluación#

Prueba T7.1 — Análisis de permisos (20 min) Se muestra una tabla con cinco apps hipotéticas y las funciones que implementan (grabar vídeo, acceder a la galería, reproducir audio, usar GPS, leer contactos). El alumno debe indicar qué permisos necesita declarar cada app en el Manifest y cuáles deben solicitarse en tiempo de ejecución.

Prueba T7.2 — Depuración de código multimedia (20 min) Se entrega un ViewModel con MediaPlayer con cuatro errores: no se llama a release() en onCleared(), se llama a start() antes de prepare(), se usa prepare() en lugar de prepareAsync() para una URL remota, y no se gestiona setOnErrorListener. El alumno los identifica y corrige.

Prueba T7.3 — Defensa oral (5 min por alumno) El profesor pregunta: "¿Por qué CameraX es preferible a Camera2 en un proyecto nuevo?", "¿Qué diferencia hay entre PickVisualMedia y ACTION_GET_CONTENT en cuanto a permisos?", "¿Por qué se usa DisposableEffect para gestionar el ciclo de vida de ExoPlayer y no LaunchedEffect?"


Referencias#

Calendar  Última modificación: viernes, 26 de junio de 2026