vela: formato de audio opcional — vigilia en .aac, día en .m4a

El contenedor ahora es una opción (Ajustes) con defaults por modo:
- vigilia nocturna → AAC crudo (ADTS .aac): a prueba de cortes, si el
  proceso muere a media noche el archivo se reproduce hasta donde llegó
  (no depende de un índice final).
- grabar / reunión → .m4a (MP4): mejor búsqueda; son sesiones atendidas.

Refactor del codificador: interfaz Codificador + base común (posee el
MediaCodec AAC-LC) + dos contenedores (CodificadorM4a con MediaMuxer,
CodificadorAac que escribe frames ADTS con su cabecera de 7 bytes a un
flujo plano). Factoría crearCodificador(formato, ...).

Sesion/Repositorio guardan "fmt" (sesiones viejas = .m4a); el nombre de
archivo usa la extensión del formato. Ajustes: formatoVigilia (def AAC) y
formatoDia (def M4A) con selector en la pantalla de ajustes.

NO compilado aquí (sin SDK en este equipo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-07-10 01:17:44 +00:00
co-authored by Claude Opus 4.8
parent 81bd1537ab
commit 1aeaadc902
10 changed files with 303 additions and 117 deletions
@@ -15,6 +15,7 @@ import com.rizoma.vela.audio.GrabadoraHolder
import com.rizoma.vela.audio.Transcriptor
import com.rizoma.vela.data.Ajustes
import com.rizoma.vela.data.AjustesActuales
import com.rizoma.vela.data.Formato
import com.rizoma.vela.data.Modo
import com.rizoma.vela.data.Repositorio
import com.rizoma.vela.data.Sesion
@@ -160,6 +161,8 @@ class VelaViewModel(app: Application) : AndroidViewModel(app) {
fun setPreRoll(v: Int) = viewModelScope.launch { ajustes.setPreRoll(v) }
fun setCola(v: Int) = viewModelScope.launch { ajustes.setCola(v) }
fun setMantenerPantalla(v: Boolean) = viewModelScope.launch { ajustes.setMantenerPantalla(v) }
fun setFormatoVigilia(v: Formato) = viewModelScope.launch { ajustes.setFormatoVigilia(v) }
fun setFormatoDia(v: Formato) = viewModelScope.launch { ajustes.setFormatoDia(v) }
override fun onCleared() {
runCatching { getApplication<Application>().unbindService(conexion) }
@@ -0,0 +1,131 @@
package com.rizoma.vela.audio
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import com.rizoma.vela.data.Formato
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Codificador de audio: se le entrega PCM 16-bit mono **solo cuando hay
* sonido** y produce un archivo. El "reloj" del archivo ([currentFileUs])
* avanza únicamente con las muestras entregadas, así el audio queda contiguo
* (silencios compactados) aunque entre fragmento y fragmento pasen horas.
*
* Uso desde un único hilo (el de captura).
*/
interface Codificador {
fun feed(pcm: ShortArray, n: Int)
fun currentFileUs(): Long
fun finish()
}
/** Crea el codificador del [formato] pedido. */
fun crearCodificador(
formato: Formato, sampleRate: Int, bitRate: Int, salida: File,
): Codificador = when (formato) {
Formato.AAC -> CodificadorAac(sampleRate, bitRate, salida)
Formato.M4A -> CodificadorM4a(sampleRate, bitRate, salida)
}
/**
* Base común: posee el `MediaCodec` AAC-LC (framework, sin deps ni LAME),
* gestiona entrada/salida y delega la escritura al contenedor concreto.
*/
abstract class CodificadorBase(
protected val sampleRate: Int,
bitRate: Int,
) : Codificador {
private val codec: MediaCodec =
MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
private val info = MediaCodec.BufferInfo()
private var muestras = 0L
private var terminado = false
init {
val fmt = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, 1,
).apply {
setInteger(
MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC,
)
setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
}
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start()
}
final override fun currentFileUs(): Long = muestras * 1_000_000L / sampleRate
final override fun feed(pcm: ShortArray, n: Int) {
if (terminado || n <= 0) return
val idx = codec.dequeueInputBuffer(10_000)
if (idx >= 0) {
val buf = codec.getInputBuffer(idx) ?: return
buf.clear()
buf.order(ByteOrder.LITTLE_ENDIAN)
buf.asShortBuffer().put(pcm, 0, n)
codec.queueInputBuffer(idx, 0, n * 2, currentFileUs(), 0)
muestras += n
}
drenar(false)
}
private fun drenar(fin: Boolean) {
while (true) {
val idx = codec.dequeueOutputBuffer(info, if (fin) 10_000 else 0)
when {
idx == MediaCodec.INFO_TRY_AGAIN_LATER -> if (!fin) return
idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> onFormato(codec.outputFormat)
idx >= 0 -> {
val buf = codec.getOutputBuffer(idx)
// El buffer de configuración del códec no es audio: descártalo.
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) info.size = 0
if (info.size > 0 && buf != null) {
buf.position(info.offset)
buf.limit(info.offset + info.size)
onMuestra(buf, info)
}
codec.releaseOutputBuffer(idx, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
}
}
}
}
final override fun finish() {
if (terminado) return
terminado = true
try {
val idx = codec.dequeueInputBuffer(10_000)
if (idx >= 0) {
codec.queueInputBuffer(
idx, 0, 0, currentFileUs(), MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
}
drenar(true)
} catch (_: Exception) {
// pase lo que pase, soltamos los recursos abajo
} finally {
runCatching { codec.stop() }
runCatching { codec.release() }
onCerrar()
}
}
/** El códec anunció su formato de salida (útil para el muxer). */
protected abstract fun onFormato(format: MediaFormat)
/** Un trozo de AAC codificado, listo para escribir (buffer ya posicionado). */
protected abstract fun onMuestra(buffer: ByteBuffer, info: MediaCodec.BufferInfo)
/** Cierre del contenedor. */
protected abstract fun onCerrar()
}
@@ -1,134 +1,65 @@
package com.rizoma.vela.audio
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Codificador AAC-LC → contenedor MP4 (`.m4a`), todo con el framework (sin deps
* nativas ni LAME). Se le entrega PCM 16-bit mono **solo cuando hay sonido**;
* los silencios no se codifican, así el archivo queda contiguo y compacto.
*
* El "reloj" del archivo ([currentFileUs]) avanza únicamente con las muestras
* realmente entregadas, no con el reloj de pared. Por eso el audio no tiene
* huecos aunque entre fragmento y fragmento pasen horas.
*
* Uso desde un único hilo (el hilo de captura): [feed] por cada trozo con
* sonido y [finish] al terminar.
* AAC-LC en flujo crudo **ADTS** (`.aac`): a cada trozo se le antepone su
* cabecera ADTS de 7 bytes y se vuelca a un archivo plano, sin muxer. Como no
* hay índice global, si el proceso muere a mitad el archivo **sigue siendo
* reproducible hasta donde llegó** — justo lo que quieres para velar toda la
* noche desatendido. La búsqueda es aproximada (sin índice).
*/
class CodificadorAac(
private val sampleRate: Int,
private val bitRate: Int,
salida: File,
) {
private val codec: MediaCodec =
MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
private val muxer: MediaMuxer =
MediaMuxer(salida.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
private val info = MediaCodec.BufferInfo()
sampleRate: Int, bitRate: Int, salida: File,
) : CodificadorBase(sampleRate, bitRate) {
private var pista = -1
private var muxerArrancado = false
private var terminado = false
private val out = BufferedOutputStream(FileOutputStream(salida))
private val freqIdx = indiceFrecuencia(sampleRate)
private val bytes = ByteArray(8192)
/** Muestras (por canal) entregadas hasta ahora → base de los timestamps. */
private var muestras = 0L
override fun onFormato(format: MediaFormat) { /* ADTS es autodescriptivo */ }
init {
val fmt = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, 1,
).apply {
setInteger(
MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC,
)
setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
}
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start()
override fun onMuestra(buffer: ByteBuffer, info: MediaCodec.BufferInfo) {
val n = info.size
out.write(cabeceraAdts(n))
buffer.get(bytes, 0, n) // buffer ya posicionado en info.offset
out.write(bytes, 0, n)
}
/** Posición actual del audio compactado, en microsegundos. */
fun currentFileUs(): Long = muestras * 1_000_000L / sampleRate
/** Entrega [n] muestras de [pcm] (mono, 16-bit) al codificador. */
fun feed(pcm: ShortArray, n: Int) {
if (terminado || n <= 0) return
val idx = codec.dequeueInputBuffer(10_000)
if (idx >= 0) {
val buf = codec.getInputBuffer(idx) ?: return
buf.clear()
buf.order(ByteOrder.LITTLE_ENDIAN)
val sh = buf.asShortBuffer()
sh.put(pcm, 0, n)
val bytes = n * 2
val pts = currentFileUs()
codec.queueInputBuffer(idx, 0, bytes, pts, 0)
muestras += n
}
drenar(false)
override fun onCerrar() {
runCatching { out.flush(); out.close() }
}
private fun drenar(fin: Boolean) {
while (true) {
val idx = codec.dequeueOutputBuffer(info, if (fin) 10_000 else 0)
when {
idx == MediaCodec.INFO_TRY_AGAIN_LATER -> {
if (!fin) return // sin salida lista todavía; seguimos capturando
// en fin: seguir esperando el EOS
}
idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
if (!muxerArrancado) {
pista = muxer.addTrack(codec.outputFormat)
muxer.start()
muxerArrancado = true
}
}
idx >= 0 -> {
val buf = codec.getOutputBuffer(idx)
// El buffer de configuración del códec no es audio: descártalo.
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
info.size = 0
}
if (info.size > 0 && muxerArrancado && buf != null) {
buf.position(info.offset)
buf.limit(info.offset + info.size)
muxer.writeSampleData(pista, buf, info)
}
codec.releaseOutputBuffer(idx, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
}
}
}
/** Cabecera ADTS de 7 bytes (AAC-LC, sin CRC) para un frame de [len] bytes. */
private fun cabeceraAdts(len: Int): ByteArray {
val perfil = 2 // AAC LC (AOT); en ADTS se codifica perfil-1
val canales = 1 // mono
val total = len + 7
val h = ByteArray(7)
h[0] = 0xFF.toByte()
h[1] = 0xF1.toByte() // syncword + MPEG-4, capa 0, sin CRC
h[2] = (((perfil - 1) shl 6) or (freqIdx shl 2) or (canales shr 2)).toByte()
h[3] = (((canales and 3) shl 6) or (total shr 11)).toByte()
h[4] = ((total shr 3) and 0xFF).toByte()
h[5] = (((total and 7) shl 5) or 0x1F).toByte()
h[6] = 0xFC.toByte()
return h
}
/** Cierra el flujo: EOS, vacía la salida pendiente y suelta todo. */
fun finish() {
if (terminado) return
terminado = true
try {
val idx = codec.dequeueInputBuffer(10_000)
if (idx >= 0) {
codec.queueInputBuffer(
idx, 0, 0, currentFileUs(),
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
}
drenar(true)
} catch (_: Exception) {
// pase lo que pase, hay que soltar los recursos abajo
} finally {
runCatching { codec.stop() }
runCatching { codec.release() }
if (muxerArrancado) runCatching { muxer.stop() }
runCatching { muxer.release() }
private companion object {
private val FRECUENCIAS = intArrayOf(
96000, 88200, 64000, 48000, 44100, 32000, 24000,
22050, 16000, 12000, 11025, 8000, 7350,
)
fun indiceFrecuencia(sr: Int): Int {
val i = FRECUENCIAS.indexOf(sr)
return if (i >= 0) i else 8 // por defecto 16 kHz
}
}
}
@@ -0,0 +1,39 @@
package com.rizoma.vela.audio
import android.media.MediaCodec
import android.media.MediaFormat
import android.media.MediaMuxer
import java.io.File
import java.nio.ByteBuffer
/**
* AAC-LC dentro de un contenedor MP4 (`.m4a`). Mejor búsqueda por índice, pero
* el índice (`moov`) se escribe al hacer [finish]: si el proceso muere antes,
* el archivo puede quedar no reproducible. Bien para grabar/reunión (atendidas).
*/
class CodificadorM4a(
sampleRate: Int, bitRate: Int, salida: File,
) : CodificadorBase(sampleRate, bitRate) {
private val muxer =
MediaMuxer(salida.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
private var pista = -1
private var arrancado = false
override fun onFormato(format: MediaFormat) {
if (!arrancado) {
pista = muxer.addTrack(format)
muxer.start()
arrancado = true
}
}
override fun onMuestra(buffer: ByteBuffer, info: MediaCodec.BufferInfo) {
if (arrancado) muxer.writeSampleData(pista, buffer, info)
}
override fun onCerrar() {
if (arrancado) runCatching { muxer.stop() }
runCatching { muxer.release() }
}
}
@@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.core.content.ContextCompat
import com.rizoma.vela.data.AjustesActuales
import com.rizoma.vela.data.Formato
import com.rizoma.vela.data.Modo
import com.rizoma.vela.data.Repositorio
import com.rizoma.vela.data.Segmento
@@ -95,9 +96,11 @@ class Grabadora(
private val frame = sampleRate / 50 // 20 ms por cuadro
private val preRollCuadros = (ajustes.preRollMs / 20).coerceAtLeast(0)
private val colaCuadros = (ajustes.colaMs / 20).coerceAtLeast(0)
// La noche va en flujo crudo (a prueba de cortes); el día en .m4a.
private val formato = if (modo == Modo.VIGILIA) ajustes.formatoVigilia else ajustes.formatoDia
private var record: AudioRecord? = null
private var codificador: CodificadorAac? = null
private var codificador: Codificador? = null
private var transcriptor: Transcriptor? = null
private var hilo: Thread? = null
@Volatile private var corriendo = false
@@ -143,7 +146,7 @@ class Grabadora(
inicioEpoch = id
val dir = repo.carpeta(id)
val bitRate = if (sampleRate <= 16_000) 32_000 else 64_000
codificador = CodificadorAac(sampleRate, bitRate, java.io.File(dir, "audio.m4a"))
codificador = crearCodificador(formato, sampleRate, bitRate, java.io.File(dir, "audio.${formato.ext}"))
record = rec
synchronized(lock) { segmentos.clear() }
@@ -269,7 +272,7 @@ class Grabadora(
}
private fun cerrarSegmento(
cod: CodificadorAac, idx: Int, inicioReal: Long, archivoUs: Long,
cod: Codificador, idx: Int, inicioReal: Long, archivoUs: Long,
pico: Float, suma: Float, frames: Int,
) {
val durMs = (cod.currentFileUs() - archivoUs) / 1000
@@ -304,7 +307,7 @@ class Grabadora(
/** Reescribe el log completo. Debe llamarse dentro de synchronized(lock). */
private fun guardarLog() {
repo.escribirLog(id, inicioEpoch, sampleRate, modo, lastReal, lastSonora, ArrayList(segmentos))
repo.escribirLog(id, inicioEpoch, sampleRate, modo, formato, lastReal, lastSonora, ArrayList(segmentos))
}
private fun publicarMedidor(db: Float, fileUs: Long) {
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@@ -21,6 +22,10 @@ data class AjustesActuales(
val preRollMs: Int = 300,
val colaMs: Int = 800,
val mantenerPantalla: Boolean = false,
// La noche desatendida por defecto en flujo crudo (a prueba de cortes); el
// día (grabar/reunión) en contenedor .m4a (mejor búsqueda).
val formatoVigilia: Formato = Formato.AAC,
val formatoDia: Formato = Formato.M4A,
)
/**
@@ -36,9 +41,14 @@ class Ajustes(private val context: Context) {
preRollMs = p[PREROLL] ?: 300,
colaMs = p[COLA] ?: 800,
mantenerPantalla = p[PANTALLA] ?: false,
formatoVigilia = formatoDe(p[FMT_VIGILIA], Formato.AAC),
formatoDia = formatoDe(p[FMT_DIA], Formato.M4A),
)
}
private fun formatoDe(v: String?, def: Formato): Formato =
runCatching { if (v != null) Formato.valueOf(v) else def }.getOrDefault(def)
suspend fun actuales(): AjustesActuales = flujo.first()
suspend fun setSensibilidad(v: Float) = context.dataStore.edit { it[SENSIBILIDAD] = v }
@@ -46,6 +56,8 @@ class Ajustes(private val context: Context) {
suspend fun setPreRoll(v: Int) = context.dataStore.edit { it[PREROLL] = v }
suspend fun setCola(v: Int) = context.dataStore.edit { it[COLA] = v }
suspend fun setMantenerPantalla(v: Boolean) = context.dataStore.edit { it[PANTALLA] = v }
suspend fun setFormatoVigilia(v: Formato) = context.dataStore.edit { it[FMT_VIGILIA] = v.name }
suspend fun setFormatoDia(v: Formato) = context.dataStore.edit { it[FMT_DIA] = v.name }
private companion object {
val SENSIBILIDAD = floatPreferencesKey("sensibilidad")
@@ -53,5 +65,7 @@ class Ajustes(private val context: Context) {
val PREROLL = intPreferencesKey("preroll_ms")
val COLA = intPreferencesKey("cola_ms")
val PANTALLA = booleanPreferencesKey("mantener_pantalla")
val FMT_VIGILIA = stringPreferencesKey("fmt_vigilia")
val FMT_DIA = stringPreferencesKey("fmt_dia")
}
}
@@ -22,6 +22,7 @@ class Repositorio(context: Context) {
inicioEpoch: Long,
sampleRate: Int,
modo: Modo,
formato: Formato,
duracionRealMs: Long,
duracionSonoraMs: Long,
segmentos: List<Segmento>,
@@ -31,6 +32,7 @@ class Repositorio(context: Context) {
put("inicioEpoch", inicioEpoch)
put("sampleRate", sampleRate)
put("modo", modo.name)
put("fmt", formato.name)
put("duracionRealMs", duracionRealMs)
put("duracionSonoraMs", duracionSonoraMs)
put("segmentos", JSONArray().apply {
@@ -89,6 +91,9 @@ class Repositorio(context: Context) {
sampleRate = o.optInt("sampleRate", 16000),
modo = runCatching { Modo.valueOf(o.optString("modo", "NORMAL")) }
.getOrDefault(Modo.NORMAL),
// Sesiones viejas no tienen "fmt": eran .m4a.
formato = runCatching { Formato.valueOf(o.optString("fmt", "M4A")) }
.getOrDefault(Formato.M4A),
duracionRealMs = o.optLong("duracionRealMs"),
duracionSonoraMs = o.optLong("duracionSonoraMs"),
segmentos = segs,
@@ -31,6 +31,17 @@ data class Segmento(
*/
enum class Modo { NORMAL, VIGILIA, REUNION }
/**
* Contenedor del audio.
* - AAC: flujo crudo ADTS (`.aac`). **A prueba de cortes**: si el proceso muere
* a media grabación, se reproduce hasta donde llegó. Ideal para la vigilia
* nocturna desatendida. Búsqueda aproximada (sin índice).
* - M4A: contenedor MP4 (`.m4a`). Mejor búsqueda, pero el índice (moov) solo se
* escribe al parar → un corte a mitad puede dejarlo no reproducible. Bien para
* grabar/reunión, que son atendidas.
*/
enum class Formato(val ext: String) { AAC("aac"), M4A("m4a") }
/**
* Una sesión = una carpeta con `audio.m4a` (audio compactado) y `log.json`
* (la línea de tiempo del diálogo).
@@ -40,6 +51,7 @@ data class Sesion(
val inicioEpoch: Long,
val sampleRate: Int,
val modo: Modo,
val formato: Formato,
/** Duración real de reloj de toda la sesión, ms. */
val duracionRealMs: Long,
/** Duración del audio grabado (sin silencios), ms. */
@@ -47,7 +59,7 @@ data class Sesion(
val segmentos: List<Segmento>,
val dir: File,
) {
val audio: File get() = File(dir, "audio.m4a")
val audio: File get() = File(dir, "audio.${formato.ext}")
/** Fracción de la noche que hubo sonido: útil para ver de un vistazo. */
val fraccionSonora: Float
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rizoma.vela.data.AjustesActuales
import com.rizoma.vela.data.Formato
import com.rizoma.vela.ui.theme.MuyTenue
import com.rizoma.vela.ui.theme.NeonCian
import com.rizoma.vela.ui.theme.NeonVerde
@@ -44,6 +45,8 @@ fun AjustesScreen(
onPreRoll: (Int) -> Unit,
onCola: (Int) -> Unit,
onMantenerPantalla: (Boolean) -> Unit,
onFormatoVigilia: (Formato) -> Unit,
onFormatoDia: (Formato) -> Unit,
onVolver: () -> Unit,
) {
Column(
@@ -84,6 +87,19 @@ fun AjustesScreen(
OpcionSR("44.1 kHz · alta", 44_100, cfg.sampleRate, onSampleRate)
}
Spacer(Modifier.height(20.dp))
Text("formato de audio", color = Tenue, fontSize = 15.sp, fontWeight = FontWeight.Bold)
Text(
".aac = flujo a prueba de cortes (si se muere la batería, se oye " +
"hasta donde llegó). .m4a = mejor para buscar, pero un corte a " +
"media grabación puede dañarlo.",
color = MuyTenue, fontSize = 12.sp,
)
Spacer(Modifier.height(10.dp))
FormatoFila("vigilia nocturna", cfg.formatoVigilia, onFormatoVigilia)
Spacer(Modifier.height(8.dp))
FormatoFila("grabar / reunión", cfg.formatoDia, onFormatoDia)
Spacer(Modifier.height(20.dp))
Row(
Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
@@ -130,6 +146,36 @@ private fun Deslizador(
}
}
@Composable
private fun FormatoFila(etiqueta: String, actual: Formato, onClick: (Formato) -> Unit) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(etiqueta, color = Tenue, fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Chip(".aac", actual == Formato.AAC) { onClick(Formato.AAC) }
Chip(".m4a", actual == Formato.M4A) { onClick(Formato.M4A) }
}
}
}
@Composable
private fun Chip(texto: String, sel: Boolean, onClick: () -> Unit) {
Text(
texto,
color = if (sel) Vacio else Tenue, fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (sel) NeonVerde else Superficie)
.border(1.dp, NeonVerde.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
.clickable { onClick() }
.padding(horizontal = 14.dp, vertical = 9.dp),
)
}
@Composable
private fun OpcionSR(texto: String, sr: Int, actual: Int, onClick: (Int) -> Unit) {
val sel = sr == actual
@@ -74,6 +74,8 @@ fun VelaApp(vm: VelaViewModel) {
onPreRoll = { vm.setPreRoll(it) },
onCola = { vm.setCola(it) },
onMantenerPantalla = { vm.setMantenerPantalla(it) },
onFormatoVigilia = { vm.setFormatoVigilia(it) },
onFormatoDia = { vm.setFormatoDia(it) },
onVolver = { vm.volver() },
)
}