Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96354c40f |
No files matched your search
@@ -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é
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/jetpack/compose)
|
||||
[](https://developer.android.com/media/media3)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
[](#-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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user