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 permisosActividades 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#
- Introducción a CameraX — Android Developers
- Implementar vista previa de CameraX — Android Developers
- Capturar imágenes con CameraX — Android Developers
- Grabar vídeo con CameraX — Android Developers
- Selector de fotos de Android — Android Developers
- Descripción general de MediaPlayer — Android Developers
- Introducción a Media3 — Android Developers
- Releases de CameraX — Android Developers
- Releases de Media3 — Android Developers
- Anexo B4-A5 — Referencia: Permisos en Android moderno, FileProvider y WorkManager avanzado