1 Commits
Author SHA1 Message Date
tuxgyver b96354c40f 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.
2026-09-13 12:15:46 +02:00
7 changed files with 159 additions and 30 deletions

No files matched your search

+20
View File
@@ -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é
+2 -1
View File
@@ -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 |
---
+1 -1
View File
@@ -1 +1 @@
1.3.0
1.4.0
@@ -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) {