From b96354c40fa30e5fc879a203f4a89ef592706ad0 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 13 Sep 2026 12:15:46 +0200 Subject: [PATCH] =?UTF-8?q?Les=20enregistrements=20radio=20portent=20le=20?= =?UTF-8?q?nom=20du=20morceau=20capt=C3=A9=20(1.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 20 +++ README.md | 3 +- VERSION | 2 +- .../fr/tuxgyver/cachette/radio/Recorder.kt | 6 + .../cachette/radio/RecordingService.kt | 123 +++++++++++++++--- .../cachette/ui/offline/DownloadsScreen.kt | 23 +++- .../tuxgyver/cachette/ui/radio/RadioScreen.kt | 12 +- 7 files changed, 159 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3e3cc..8ffb2c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Toutes les évolutions notables de Cachette sont documentées ici. Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le versionnage [SemVer](https://semver.org/lang/fr/). +## [1.4.0] — 2026-09-13 + +### Ajouté + +- Titrage automatique des enregistrements radio. Le service demande désormais les métadonnées + ICY, ce qui fait annoncer par la station le morceau diffusé : l'enregistrement prend son nom, + la station passe en sous-titre, et le titre en cours s'affiche pendant la capture comme dans + la notification. Ces octets de métadonnées sont retirés avant écriture — laissés dans le + fichier, ils le rendraient illisible. Les stations qui n'en diffusent pas gardent le nom + « Station — date ». + +### Corrigé + +- Les enregistrements radio n'apparaissaient pas dans l'onglet « Hors ligne », qui ne listait que + ce qui porte un état de téléchargement. L'écran annonçait donc un nombre d'éléments disponibles + sans réseau en omettant précisément ceux qui l'étaient déjà. La taille affichée additionne + maintenant le cache et les enregistrements, et « Tout libérer » précise qu'il ne les touche pas. +- Les titres comportant une apostrophe étaient tronqués à la première rencontrée : « Ain't That a + Kick in the Head » devenait « Ain ». La valeur est lue jusqu'à la fin du champ ICY. + ## [1.3.0] — 2026-09-13 ### Ajouté diff --git a/README.md b/README.md index 7e8f536..3be5447 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-1.3.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-1.4.0-6B4EFF)](VERSION) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les @@ -43,6 +43,7 @@ sans la moindre connexion. | 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti | | 🔁 **Reprise automatique** | Réseau coupé : mise en attente et reprise. Échec serveur : relance seule après 5 s, 15 s, 45 s | | 📻 **Radio** | Les stations les plus écoutées, en direct — et enregistrables d'un appui | +| 🏷️ **Titrage automatique** | L'enregistrement prend le nom du morceau annoncé par la station | | 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation | --- diff --git a/VERSION b/VERSION index f0bb29e..88c5fb8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 +1.4.0 diff --git a/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt b/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt index 778a0b4..77744da 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt @@ -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() } diff --git a/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt b/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt index 14c9224..2b392e9 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt @@ -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) diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt index 882f84c..d2b12e1 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt @@ -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 = { diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/radio/RadioScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/radio/RadioScreen.kt index b90f914..b865e8c 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/radio/RadioScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/radio/RadioScreen.kt @@ -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) {