Les enregistrements radio portent le nom du morceau capté (1.4.0)

Un enregistrement s'appelait « Station — date », ce qui oblige à rouvrir le
fichier pour savoir ce qu'il contient. Le flux ICY annonce le morceau diffusé :
le service le demande, le premier titre capté nomme l'enregistrement, la station
devient le sous-titre, et le morceau en cours s'affiche pendant la capture.

Ces octets de métadonnées ne sont pas de l'audio et sont retirés avant écriture ;
laissés dans le fichier, ils le rendraient illisible. La valeur du champ se lit
jusqu'à sa fin, et non jusqu'à la première apostrophe, sans quoi « Ain't That a
Kick in the Head » se réduirait à « Ain ».

L'onglet Hors ligne ignorait par ailleurs les enregistrements : n'ayant aucun
état de téléchargement, ils échappaient à son filtre alors qu'ils sont, par
nature, ce qu'il y a de plus disponible sans réseau. Ils y figurent désormais,
la taille annoncée les additionne au cache, et « Tout libérer » dit clairement
qu'il les épargne.
This commit is contained in:
tuxgyver committed 2026-09-13 12:15:46 +02:00
1 parent 4017c71ca7
commit b96354c40f
7 files changed
+159 -30

No files matched your search

@@ -8,6 +8,8 @@ data class RecordingStatus(
val station: RadioStation? = null,
val startedAt: Long = 0L,
val bytes: Long = 0L,
/** Titre annoncé par le flux, quand la station en diffuse un. */
val currentTitle: String? = null,
) {
val isRecording: Boolean get() = station != null
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
@@ -31,6 +33,10 @@ object Recorder {
_status.value = _status.value.copy(bytes = bytes)
}
internal fun title(title: String) {
_status.value = _status.value.copy(currentTitle = title)
}
internal fun stopped() {
_status.value = RecordingStatus()
}
@@ -23,6 +23,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
@@ -44,6 +45,7 @@ class RecordingService : Service() {
private var job: Job? = null
private var target: File? = null
private var station: RadioStation? = null
private var firstTitle: String? = null
override fun onBind(intent: Intent?): IBinder? = null
@@ -68,6 +70,7 @@ class RecordingService : Service() {
private fun begin(incoming: RadioStation) {
station = incoming
firstTitle = null
Recorder.started(incoming)
startForegroundCompat(notification(incoming, 0L))
@@ -78,30 +81,25 @@ class RecordingService : Service() {
job = scope.launch {
runCatching {
val connection = openConnection(incoming.streamUrl, readTimeoutMs = 30_000)
// « Icy-MetaData: 1 » demande à la station d'intercaler le titre en cours dans le
// flux. Ces octets ne sont pas de l'audio : ils doivent être retirés avant écriture,
// sinon le fichier obtenu est corrompu.
val connection = openConnection(incoming.streamUrl, readTimeoutMs = 30_000).apply {
setRequestProperty("Icy-MetaData", "1")
}
try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Flux indisponible (${connection.responseCode})")
}
val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0)
connection.inputStream.use { input ->
FileOutputStream(file).use { output ->
val buffer = ByteArray(32 * 1024)
var total = 0L
var lastNotified = 0L
while (isActive) {
val read = input.read(buffer)
if (read <= 0) break
output.write(buffer, 0, read)
total += read
// Une notification par seconde suffit : le flux, lui, défile vite.
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
lastNotified = total
Recorder.progress(total)
notifyProgress(incoming, total)
}
if (metaInterval > 0) {
copyWithTitles(input, output, metaInterval, incoming)
} else {
copyPlain(input, output, incoming)
}
output.flush()
Recorder.progress(total)
}
}
} finally {
@@ -113,6 +111,86 @@ class RecordingService : Service() {
}
}
/** Flux sans métadonnées : simple recopie. */
private suspend fun copyPlain(
input: java.io.InputStream,
output: FileOutputStream,
station: RadioStation,
) {
val buffer = ByteArray(32 * 1024)
var total = 0L
var lastNotified = 0L
while (currentCoroutineContext().isActive) {
val read = input.read(buffer)
if (read <= 0) break
output.write(buffer, 0, read)
total += read
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
lastNotified = total
Recorder.progress(total)
notifyProgress(station, total)
}
}
Recorder.progress(total)
}
/**
* Flux ICY : `interval` octets d'audio, puis un bloc de métadonnées à retirer.
* Le premier titre rencontré nomme l'enregistrement ; les suivants alimentent la notification.
*/
private suspend fun copyWithTitles(
input: java.io.InputStream,
output: FileOutputStream,
interval: Int,
station: RadioStation,
) {
val audio = ByteArray(interval)
var total = 0L
var lastNotified = 0L
while (currentCoroutineContext().isActive) {
if (!input.readFully(audio, interval)) break
output.write(audio)
total += interval
val lengthByte = input.read()
if (lengthByte < 0) break
val metaLength = lengthByte * 16
if (metaLength > 0) {
val meta = ByteArray(metaLength)
if (!input.readFully(meta, metaLength)) break
STREAM_TITLE
.find(String(meta, Charsets.UTF_8))
?.groupValues?.get(1)
?.trim()
?.takeIf { it.isNotBlank() }
?.let { title ->
if (firstTitle == null) firstTitle = title
Recorder.title(title)
notifyProgress(station, total)
}
}
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
lastNotified = total
Recorder.progress(total)
notifyProgress(station, total)
}
}
Recorder.progress(total)
}
/** Lit exactement [count] octets, ou renvoie false si le flux s'interrompt avant. */
private fun java.io.InputStream.readFully(target: ByteArray, count: Int): Boolean {
var filled = 0
while (filled < count) {
val read = read(target, filled, count - filled)
if (read < 0) return false
filled += read
}
return true
}
/** Clôt l'enregistrement et verse le fichier obtenu dans la bibliothèque. */
private fun finish() {
job?.cancel()
@@ -122,6 +200,7 @@ class RecordingService : Service() {
val recorded = station
target = null
station = null
val captured = firstTitle
if (file != null && recorded != null && file.length() > MIN_USEFUL_BYTES) {
val elapsed = Recorder.status.value.elapsedMs
@@ -130,8 +209,10 @@ class RecordingService : Service() {
id = "rec:${file.name}",
source = SourceKind.RECORDING,
kind = MediaKind.AUDIO,
title = "${recorded.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}",
subtitle = "Enregistrement radio",
// Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant.
title = captured
?: "${recorded.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}",
subtitle = if (captured != null) recorded.name else "Enregistrement radio",
uri = file.toURI().toString(),
artworkUri = recorded.faviconUrl,
durationMs = elapsed,
@@ -187,7 +268,10 @@ class RecordingService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline)
.setContentTitle("Enregistrement de ${station.name}")
.setContentText(if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…")
.setContentText(
Recorder.status.value.currentTitle
?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…"
)
.setContentIntent(open)
.addAction(0, "Arrêter", stop)
.setOngoing(true)
@@ -228,6 +312,7 @@ class RecordingService : Service() {
private const val CHANNEL_ID = "cachette_recording"
private const val NOTIFY_EVERY_BYTES = 256 * 1024L
private const val MIN_USEFUL_BYTES = 16 * 1024L
private val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
fun start(context: Context, station: RadioStation) {
val intent = Intent(context, RecordingService::class.java)
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.ui.components.MediaRow
import fr.tuxgyver.cachette.ui.components.formatBytes
import kotlinx.coroutines.Dispatchers
@@ -53,17 +54,25 @@ fun DownloadsScreen(
var confirmClear by remember { mutableStateOf(false) }
var cacheSize by remember { mutableLongStateOf(0L) }
LaunchedEffect(offlineStates) {
cacheSize = withContext(Dispatchers.IO) { container.offline.cacheSizeBytes() }
}
// Un enregistrement radio n'a pas d'état de téléchargement : le fichier est déjà là.
// L'écran annoncerait sinon « disponibles sans réseau » en omettant ce qui l'est le plus.
val cached = remember(entries, offlineStates) {
entries.filter { offlineStates[it.id] != null }
entries.filter { offlineStates[it.id] != null || it.source == SourceKind.RECORDING }
.sortedByDescending { offlineStates[it.id]?.isActive == true }
}
val completed = cached.count { offlineStates[it.id]?.isComplete == true }
val recordings = cached.filter { it.source == SourceKind.RECORDING }
val completed = cached.count {
offlineStates[it.id]?.isComplete == true || it.source == SourceKind.RECORDING
}
val running = cached.count { offlineStates[it.id]?.isActive == true }
LaunchedEffect(offlineStates, recordings) {
cacheSize = withContext(Dispatchers.IO) {
container.offline.cacheSizeBytes() + recordings.sumOf { it.sizeBytes ?: 0L }
}
}
LazyColumn(
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
@@ -118,7 +127,7 @@ fun DownloadsScreen(
onDismissRequest = { confirmClear = false },
title = { Text("Vider la cachette ?") },
text = {
Text("Tout le contenu mis de côté sera supprimé de l'appareil (${formatBytes(cacheSize)}). Les fichiers déjà présents sur l'appareil ne sont pas touchés.")
Text("Le contenu téléchargé sera supprimé de l'appareil. Les fichiers déjà présents et vos enregistrements radio ne sont pas touchés.")
},
confirmButton = {
TextButton(onClick = {
@@ -133,6 +133,7 @@ fun RadioScreen(
}
RecordingBanner(
station = status.station!!,
title = status.currentTitle,
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
bytes = status.bytes,
onStop = {
@@ -242,6 +243,7 @@ private fun RadioStation.asEntry() = MediaEntry(
@Composable
private fun RecordingBanner(
station: RadioStation,
title: String?,
elapsedMs: Long,
bytes: Long,
onStop: () -> Unit,
@@ -265,14 +267,20 @@ private fun RecordingBanner(
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = station.name,
text = title ?: station.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${formatDuration(elapsedMs)} · ${formatBytes(bytes)}",
text = listOfNotNull(
title?.let { station.name },
formatDuration(elapsedMs),
formatBytes(bytes),
).joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Button(onClick = onStop) {