Titre en cours, guet d'un morceau, catalogue underground (1.5.0)

On ne savait pas ce qui passait sur une station sans la lancer. Le morceau
diffusé est maintenant lu dans le flux lui-même — aucun annuaire ne l'expose —
pour les douze premières stations affichées, quatre à la fois. Au-delà, ouvrir
une connexion par station ne se justifierait plus.

De là découle le guet : on nomme un morceau ou un artiste, et l'enregistrement
se déclenche dès que la station l'annonce, puis s'arrête à la fin du morceau.
Le flux est lu sans être écrit pendant l'attente, et une station qui n'annonce
rien refuse la programmation plutôt que d'attendre indéfiniment.

Les enregistrements rejoignent Music/Cachette et MediaStore : ils deviennent
visibles des autres lecteurs et survivent à la désinstallation. Le nom du
fichier doit porter le titre, car en publiant le fichier MediaStore le relit et
réécrit ce champ depuis le nom ; et le scan local ignore ce dossier, faute de
quoi chaque enregistrement apparaîtrait deux fois. Chacun se supprime désormais
seul, fichier, entrée MediaStore et référence comprises.

Enfin, un catalogue Underground restreint aux collections netlabels et
freemusicarchive, où tout est publié sous licence libre par les artistes :
le fonds indépendant que la recherche généraliste noyait.
This commit is contained in:
tuxgyver committed 2026-09-13 12:56:16 +02:00
1 parent b96354c40f
commit 1e523b2ad4
14 files changed
+482 -54

No files matched your search

+27
View File
@@ -5,6 +5,33 @@ 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.5.0] — 2026-09-13
### Ajouté
- **Titre en cours par station.** Aucun annuaire ne l'expose : le morceau diffusé est lu dans le
flux lui-même. Les douze premières stations affichées sont sondées quatre à la fois, et le
résultat conservé — au-delà, le nombre de connexions ne se justifierait plus. Les stations qui
n'annoncent rien gardent leur description.
- **Guet d'un titre.** On indique un morceau ou un artiste, et l'enregistrement se déclenche dès
que la station l'annonce, puis s'arrête à la fin du morceau. Le flux est lu sans être écrit
pendant l'attente. Une station sans métadonnées refuse la programmation plutôt que d'attendre
indéfiniment.
- **Catalogue Underground.** Restreint aux collections `netlabels` et `freemusicarchive`, où tout
est publié sous licence libre par les artistes : environ 94 000 morceaux indépendants, que la
recherche généraliste noyait.
- **Suppression individuelle d'un enregistrement** depuis l'écran Hors ligne : fichier, entrée
MediaStore et référence en bibliothèque sont retirés ensemble.
### Modifié
- Les enregistrements sont désormais écrits dans `Music/Cachette` et déclarés dans MediaStore
(Android 10 et plus) : ils deviennent visibles des autres lecteurs et survivent à la
désinstallation. Le scan local ignore ce dossier, faute de quoi chaque enregistrement
apparaîtrait deux fois.
- Le nom de fichier porte le titre du morceau. En publiant le fichier, MediaStore le relit et
réécrit le champ `TITLE` depuis ce nom : l'y inscrire directement ne survivait pas.
## [1.4.0] — 2026-09-13
### Ajouté
+20 -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.4.0-6B4EFF)](VERSION)
[![Version](https://img.shields.io/badge/Version-1.5.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
@@ -44,6 +44,8 @@ sans la moindre connexion.
| 🔁 **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 |
| 👀 **Titre en cours** | Ce que diffuse chaque station, lu dans son flux |
| ⏰ **Guet d'un titre** | L'enregistrement se déclenche quand le morceau voulu passe, et s'arrête à sa fin |
| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation |
---
@@ -59,6 +61,7 @@ sans la moindre connexion.
| 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue |
| 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres |
| 📻 Radio | ✅ | Annuaire Radio Browser, direct et enregistrement (flux HLS exclus) |
| 🎧 Underground | ✅ | Netlabels et Free Music Archive : ~94 000 morceaux d'artistes indépendants |
### 🔎 Catalogues évalués puis écartés
@@ -191,6 +194,22 @@ sortantes sont celles des URL que vous ajoutez et des catalogues que vous interr
---
## 📼 Où vont les enregistrements
À partir d'Android 10, dans `Music/Cachette/`, déclarés dans MediaStore : ils sont donc visibles
de tous les lecteurs de l'appareil et survivent à la désinstallation. En deçà, ils restent dans le
dossier privé de l'application, MediaStore y réclamant une permission de stockage dont Cachette n'a
pas besoin par ailleurs.
Le nom du fichier porte le titre du morceau — et pas seulement la colonne `TITLE` : en publiant le
fichier, MediaStore le relit et réécrit ce champ à partir du nom. Le scan local, lui, ignore
`Music/Cachette` pour ne pas afficher deux fois ce que la bibliothèque connaît déjà.
Chaque enregistrement se supprime individuellement depuis l'écran **Hors ligne** : fichier, entrée
MediaStore et référence en bibliothèque partent ensemble. « Tout libérer » ne les touche pas.
---
## 🆕 Mises à jour
Cachette étant distribuée hors magasin, elle surveille elle-même les releases du dépôt.
+1 -1
View File
@@ -1 +1 @@
1.4.0
1.5.0
@@ -48,6 +48,10 @@ class AppContainer(context: Context) {
val catalogs: Map<CatalogId, Catalog> = listOf(
ArchiveOrgCatalog(),
ArchiveOrgCatalog(
id = CatalogId.UNDERGROUND,
collections = listOf("netlabels", "freemusicarchive"),
),
LibriVoxCatalog(rssImporter),
WikimediaCatalog(),
).associateBy { it.id }
@@ -55,7 +55,7 @@ class LocalMediaScanner(private val context: Context) {
context.contentResolver.query(
collection,
projection,
"${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0",
audioSelection(),
null,
null,
)?.use { cursor ->
@@ -123,6 +123,19 @@ class LocalMediaScanner(private val context: Context) {
return entries
}
/**
* Musique et podcasts, hors enregistrements de Cachette : ceux-ci sont déjà décrits par la
* bibliothèque, et les relire ici les afficherait en double.
*/
private fun audioSelection(): String {
val base = "(${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0)"
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"$base AND ${MediaStore.Audio.Media.RELATIVE_PATH} NOT LIKE 'Music/Cachette%'"
} else {
base
}
}
private fun albumArtUri(albumId: Long): Uri =
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
@@ -30,6 +30,49 @@ class RadioDirectory {
query("?limit=60&hidebroken=true&order=clickcount&reverse=true" +
"&name=${URLEncoder.encode(name.trim(), "UTF-8")}")
/**
* Le morceau diffusé à l'instant par une station.
*
* Aucun annuaire ne l'expose : il faut ouvrir le flux, lire le premier bloc de métadonnées
* ICY, puis refermer. C'est une connexion par station — d'où l'appel par petits paquets et
* la mise en cache par l'appelant plutôt qu'un balayage systématique.
*/
suspend fun nowPlaying(streamUrl: String): String? = withContext(Dispatchers.IO) {
runCatching {
val connection = openConnection(streamUrl, readTimeoutMs = 8_000).apply {
setRequestProperty("Icy-MetaData", "1")
}
try {
if (connection.responseCode !in 200..299) return@runCatching null
val interval = connection.getHeaderFieldInt("icy-metaint", 0)
if (interval <= 0) return@runCatching null
connection.inputStream.use { input ->
val skip = ByteArray(interval)
var filled = 0
while (filled < interval) {
val read = input.read(skip, filled, interval - filled)
if (read < 0) return@runCatching null
filled += read
}
val length = input.read().takeIf { it >= 0 }?.times(16) ?: return@runCatching null
if (length == 0) return@runCatching null
val meta = ByteArray(length)
filled = 0
while (filled < length) {
val read = input.read(meta, filled, length - filled)
if (read < 0) return@runCatching null
filled += read
}
STREAM_TITLE.find(String(meta, Charsets.UTF_8))
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
}
} finally {
connection.disconnect()
}
}.getOrNull()
}
private suspend fun query(suffix: String): Result<List<RadioStation>> = withContext(Dispatchers.IO) {
runCatching {
val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000)
@@ -66,6 +109,9 @@ class RadioDirectory {
private companion object {
const val BASE = "https://de1.api.radio-browser.info/json/stations/search"
/** Le titre court jusqu'à la fin du champ ICY : les apostrophes y sont fréquentes. */
val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
/** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
@@ -10,6 +10,8 @@ data class RecordingStatus(
val bytes: Long = 0L,
/** Titre annoncé par le flux, quand la station en diffuse un. */
val currentTitle: String? = null,
/** Titre attendu avant de commencer à écrire, si l'enregistrement a été programmé. */
val waitingFor: String? = null,
) {
val isRecording: Boolean get() = station != null
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
@@ -25,8 +27,17 @@ object Recorder {
private val _status = MutableStateFlow(RecordingStatus())
val status: StateFlow<RecordingStatus> = _status.asStateFlow()
internal fun started(station: RadioStation) {
_status.value = RecordingStatus(station, System.currentTimeMillis(), 0L)
internal fun started(station: RadioStation, waitingFor: String? = null) {
_status.value = RecordingStatus(station, System.currentTimeMillis(), 0L, waitingFor = waitingFor)
}
/** Le morceau attendu commence : l'écriture démarre maintenant. */
internal fun armed(title: String) {
_status.value = _status.value.copy(
waitingFor = null,
currentTitle = title,
startedAt = System.currentTimeMillis(),
)
}
internal fun progress(bytes: Long) {
@@ -43,9 +43,12 @@ class RecordingService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
private var target: File? = null
private var sink: RecordingSink? = null
private var sinkTitle: String = ""
private var station: RadioStation? = null
private var firstTitle: String? = null
private var trigger: String? = null
override fun onBind(intent: Intent?): IBinder? = null
@@ -62,6 +65,7 @@ class RecordingService : Service() {
return START_NOT_STICKY
}
if (job?.isActive == true) return START_STICKY
trigger = intent.getStringExtra(EXTRA_TRIGGER)?.takeIf { it.isNotBlank() }
begin(incoming)
}
}
@@ -71,13 +75,18 @@ class RecordingService : Service() {
private fun begin(incoming: RadioStation) {
station = incoming
firstTitle = null
Recorder.started(incoming)
Recorder.started(incoming, trigger)
startForegroundCompat(notification(incoming, 0L))
val directory = File(getExternalFilesDir(null) ?: filesDir, "recordings").apply { mkdirs() }
val stamp = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.FRANCE).format(Date())
val file = File(directory, "${incoming.name.sanitized()}-$stamp.${incoming.extension()}")
target = file
val extension = incoming.extension()
sinkTitle = "${incoming.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}"
val output = RecordingSink(
context = this,
fileName = "${incoming.name.sanitized()}-$stamp.$extension",
mimeType = if (extension == "aac") "audio/aac" else "audio/mpeg",
)
sink = output
job = scope.launch {
runCatching {
@@ -93,13 +102,18 @@ class RecordingService : Service() {
}
val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0)
connection.inputStream.use { input ->
FileOutputStream(file).use { output ->
output.open().use { stream ->
if (metaInterval > 0) {
copyWithTitles(input, output, metaInterval, incoming)
copyWithTitles(input, stream, metaInterval, incoming)
} else if (trigger != null) {
// Sans métadonnées, impossible de savoir quand le morceau passe.
throw IllegalStateException(
"${incoming.name} n'annonce pas ses titres : l'attente est impossible"
)
} else {
copyPlain(input, output, incoming)
copyPlain(input, stream, incoming)
}
output.flush()
stream.flush()
}
}
} finally {
@@ -114,7 +128,7 @@ class RecordingService : Service() {
/** Flux sans métadonnées : simple recopie. */
private suspend fun copyPlain(
input: java.io.InputStream,
output: FileOutputStream,
output: java.io.OutputStream,
station: RadioStation,
) {
val buffer = ByteArray(32 * 1024)
@@ -140,18 +154,24 @@ class RecordingService : Service() {
*/
private suspend fun copyWithTitles(
input: java.io.InputStream,
output: FileOutputStream,
output: java.io.OutputStream,
interval: Int,
station: RadioStation,
) {
val wanted = trigger
val audio = ByteArray(interval)
var total = 0L
var lastNotified = 0L
// Sans titre attendu, on écrit dès la première seconde ; sinon on patiente.
var writing = wanted == null
var capturedTitle: String? = null
while (currentCoroutineContext().isActive) {
if (!input.readFully(audio, interval)) break
output.write(audio)
total += interval
if (writing) {
output.write(audio)
total += interval
}
val lengthByte = input.read()
if (lengthByte < 0) break
@@ -159,16 +179,32 @@ class RecordingService : Service() {
if (metaLength > 0) {
val meta = ByteArray(metaLength)
if (!input.readFully(meta, metaLength)) break
STREAM_TITLE
val announced = 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 (announced != null) {
Recorder.title(announced)
when {
wanted == null -> if (firstTitle == null) firstTitle = announced
!writing && announced.contains(wanted, ignoreCase = true) -> {
// Le morceau attendu commence : on enregistre à partir d'ici.
writing = true
capturedTitle = announced
firstTitle = announced
Recorder.armed(announced)
}
writing && capturedTitle != null && announced != capturedTitle -> {
// Le morceau est terminé : on s'arrête sans attendre le suivant.
break
}
}
notifyProgress(station, total)
}
}
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
@@ -196,34 +232,38 @@ class RecordingService : Service() {
job?.cancel()
job = null
val file = target
val output = sink
val recorded = station
target = null
station = null
val captured = firstTitle
sink = null
station = null
trigger = null
if (file != null && recorded != null && file.length() > MIN_USEFUL_BYTES) {
if (output != null && recorded != null) {
val elapsed = Recorder.status.value.elapsedMs
val container = (application as CachetteApp).container
val entry = MediaEntry(
id = "rec:${file.name}",
source = SourceKind.RECORDING,
kind = MediaKind.AUDIO,
// 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,
addedAt = System.currentTimeMillis(),
sizeBytes = file.length(),
)
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) }
} else {
file?.delete()
// Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant.
val title = captured ?: sinkTitle
val published = output.finish(title, elapsed)
if (published != null) {
val (uri, size) = published
val container = (application as CachetteApp).container
val entry = MediaEntry(
id = "rec:${uri.hashCode()}",
source = SourceKind.RECORDING,
kind = MediaKind.AUDIO,
title = title,
subtitle = if (captured != null) recorded.name else "Enregistrement radio",
uri = uri,
artworkUri = recorded.faviconUrl,
durationMs = elapsed,
addedAt = System.currentTimeMillis(),
sizeBytes = size,
)
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) }
}
}
firstTitle = null
Recorder.stopped()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -267,7 +307,10 @@ class RecordingService : Service() {
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline)
.setContentTitle("Enregistrement de ${station.name}")
.setContentTitle(
if (Recorder.status.value.waitingFor != null) "En attente sur ${station.name}"
else "Enregistrement de ${station.name}"
)
.setContentText(
Recorder.status.value.currentTitle
?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…"
@@ -308,13 +351,14 @@ class RecordingService : Service() {
private const val EXTRA_CODEC = "codec"
private const val EXTRA_BITRATE = "bitrate"
private const val EXTRA_FAVICON = "favicon"
private const val EXTRA_TRIGGER = "trigger"
private const val NOTIFICATION_ID = 4401
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) {
fun start(context: Context, station: RadioStation, whenTitleContains: String? = null) {
val intent = Intent(context, RecordingService::class.java)
.setAction(ACTION_START)
.putExtra(EXTRA_ID, station.id)
@@ -323,6 +367,7 @@ class RecordingService : Service() {
.putExtra(EXTRA_CODEC, station.codec)
.putExtra(EXTRA_BITRATE, station.bitrate)
.putExtra(EXTRA_FAVICON, station.faviconUrl)
.putExtra(EXTRA_TRIGGER, whenTitleContains)
context.startForegroundService(intent)
}
@@ -0,0 +1,116 @@
package fr.tuxgyver.cachette.radio
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import fr.tuxgyver.cachette.model.MediaEntry
import java.io.File
import java.io.OutputStream
/**
* Destination d'un enregistrement radio.
*
* À partir d'Android 10, le fichier est écrit dans `Music/Cachette` via MediaStore : il devient
* visible de tous les lecteurs de l'appareil et survit à la désinstallation. En deçà, l'écriture
* dans MediaStore réclamerait une permission de stockage que l'application n'a pas besoin de
* demander par ailleurs, donc le fichier reste dans le dossier privé.
*/
class RecordingSink(
private val context: Context,
private val fileName: String,
private val mimeType: String,
) {
private val useMediaStore = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
private var uri: Uri? = null
private var file: File? = null
fun open(): OutputStream = if (useMediaStore) openMediaStore() else openPrivateFile()
private fun openMediaStore(): OutputStream {
val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val values = ContentValues().apply {
put(MediaStore.Audio.Media.DISPLAY_NAME, fileName)
put(MediaStore.Audio.Media.MIME_TYPE, mimeType)
put(MediaStore.Audio.Media.RELATIVE_PATH, RELATIVE_PATH)
// Tant que l'écriture dure, le fichier reste invisible des autres applications.
put(MediaStore.Audio.Media.IS_PENDING, 1)
}
val created = context.contentResolver.insert(collection, values)
?: throw IllegalStateException("Impossible de créer le fichier dans la bibliothèque système")
uri = created
return context.contentResolver.openOutputStream(created)
?: throw IllegalStateException("Impossible d'écrire dans la bibliothèque système")
}
private fun openPrivateFile(): OutputStream {
val directory = File(context.getExternalFilesDir(null) ?: context.filesDir, "recordings")
.apply { mkdirs() }
val created = File(directory, fileName)
file = created
return created.outputStream()
}
/** Publie l'enregistrement sous son titre définitif, ou renvoie null s'il est vide. */
fun finish(title: String, durationMs: Long): Pair<String, Long>? {
val target = uri
if (target != null) {
val size = context.contentResolver.openFileDescriptor(target, "r")
?.use { it.statSize } ?: 0L
if (size < MIN_USEFUL_BYTES) {
abort()
return null
}
// En levant IS_PENDING, MediaStore relit le fichier et réécrit TITLE depuis le nom :
// c'est donc le nom de fichier, et lui seul, qui doit porter le titre du morceau.
// (Vérifié : un TITLE posé ici est écrasé par ce balayage.)
val extension = fileName.substringAfterLast('.', "mp3")
context.contentResolver.update(
target,
ContentValues().apply {
put(MediaStore.Audio.Media.IS_PENDING, 0)
put(MediaStore.Audio.Media.DISPLAY_NAME, "${title.asFileName()}.$extension")
if (durationMs > 0) put(MediaStore.Audio.Media.DURATION, durationMs)
},
null,
null,
)
return target.toString() to size
}
val created = file ?: return null
if (created.length() < MIN_USEFUL_BYTES) {
created.delete()
return null
}
return created.toURI().toString() to created.length()
}
fun abort() {
uri?.let { context.contentResolver.delete(it, null, null) }
file?.delete()
uri = null
file = null
}
/** Un titre de morceau n'est pas un nom de fichier : on en retire ce qui gênerait. */
private fun String.asFileName(): String =
replace(Regex("[^\\p{L}\\p{N} ._-]"), "").trim().take(80).ifBlank { "enregistrement" }
companion object {
const val RELATIVE_PATH = "Music/Cachette"
private const val MIN_USEFUL_BYTES = 16 * 1024L
/** Supprime un enregistrement, quel que soit l'endroit où il avait été écrit. */
fun delete(context: Context, entry: MediaEntry): Boolean = runCatching {
val uri = Uri.parse(entry.uri)
when (uri.scheme) {
"content" -> context.contentResolver.delete(uri, null, null) > 0
"file" -> File(uri.path!!).delete()
else -> false
}
}.getOrDefault(false)
}
}
@@ -19,9 +19,16 @@ import java.net.URLEncoder
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
* trente éléments dont vingt-neuf seront ignorés.
*/
class ArchiveOrgCatalog : Catalog {
override val id = CatalogId.ARCHIVE
class ArchiveOrgCatalog(
override val id: CatalogId = CatalogId.ARCHIVE,
/**
* Collections auxquelles se restreindre. Vide : tout archive.org.
* `netlabels` et `freemusicarchive` ne contiennent que des publications sous licence libre,
* ce qui donne un fonds indépendant sans les dépôts de titres commerciaux qu'on croise
* ailleurs sur le site.
*/
private val collections: List<String> = emptyList(),
) : Catalog {
override val languages = COMMON_LANGUAGES
@@ -33,8 +40,13 @@ class ArchiveOrgCatalog : Catalog {
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
// dès la recherche plutôt que de les proposer puis d'échouer.
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
val collectionClause =
collections.takeIf { it.isNotEmpty() }
?.joinToString(" OR ")
?.let { " AND collection:($it)" }
.orEmpty()
val q = URLEncoder.encode(
"($query) AND mediatype:(audio OR movies)$languageClause" +
"($query) AND mediatype:(audio OR movies)$languageClause$collectionClause" +
" AND NOT access-restricted-item:true",
"UTF-8",
)
@@ -52,6 +52,12 @@ enum class CatalogId(
license = "Domaine public",
suggestion = "Jules Verne",
),
UNDERGROUND(
title = "Underground",
description = "Netlabels et Free Music Archive : des dizaines de milliers de morceaux d'artistes indépendants, publiés sous licence libre.",
license = "Creative Commons / netlabels",
suggestion = "ambient",
),
WIKIMEDIA(
title = "Wikimedia Commons",
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
@@ -14,8 +14,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -27,18 +29,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
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.radio.RecordingSink
import fr.tuxgyver.cachette.ui.components.MediaRow
import fr.tuxgyver.cachette.ui.components.formatBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
@@ -52,6 +58,9 @@ fun DownloadsScreen(
val entries by container.library.entries.collectAsStateWithLifecycle()
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
var confirmClear by remember { mutableStateOf(false) }
var pendingRecordingDelete by remember { mutableStateOf<MediaEntry?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var cacheSize by remember { mutableLongStateOf(0L) }
// Un enregistrement radio n'a pas d'état de téléchargement : le fichier est déjà là.
@@ -101,6 +110,8 @@ fun DownloadsScreen(
items(cached, key = { it.id }) { entry ->
val state = offlineStates[entry.id]
Column {
val isRecording = entry.source == SourceKind.RECORDING
Row(verticalAlignment = Alignment.CenterVertically) {
MediaRow(
entry = entry,
offline = state,
@@ -109,7 +120,17 @@ fun DownloadsScreen(
onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) },
onLongClick = { onAddToPlaylist(entry) },
modifier = Modifier.weight(1f),
)
if (isRecording) {
IconButton(onClick = { pendingRecordingDelete = entry }) {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = "Supprimer cet enregistrement",
)
}
}
}
if (state?.isActive == true) {
LinearProgressIndicator(
progress = { (state.percent / 100f).coerceIn(0f, 1f) },
@@ -122,6 +143,26 @@ fun DownloadsScreen(
}
}
pendingRecordingDelete?.let { entry ->
AlertDialog(
onDismissRequest = { pendingRecordingDelete = null },
title = { Text("Supprimer cet enregistrement ?") },
text = { Text("« ${entry.title} » sera effacé de l'appareil. Cette action est définitive : un enregistrement ne se retélécharge pas.") },
confirmButton = {
TextButton(onClick = {
scope.launch {
RecordingSink.delete(context, entry)
container.library.forget(entry.id)
}
pendingRecordingDelete = null
}) { Text("Supprimer") }
},
dismissButton = {
TextButton(onClick = { pendingRecordingDelete = null }) { Text("Annuler") }
},
)
}
if (confirmClear) {
AlertDialog(
onDismissRequest = { confirmClear = false },
@@ -22,11 +22,13 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.FiberManualRecord
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.PlayCircleOutline
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
@@ -36,6 +38,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -66,6 +69,8 @@ import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatBytes
import fr.tuxgyver.cachette.ui.components.formatDuration
import kotlinx.coroutines.delay
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
/**
@@ -92,6 +97,8 @@ fun RadioScreen(
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var nowPlaying by remember { mutableStateOf(emptyMap<String, String>()) }
var scheduleFor by remember { mutableStateOf<RadioStation?>(null) }
fun load() {
loading = true
@@ -109,6 +116,20 @@ fun RadioScreen(
LaunchedEffect(genre) { load() }
// Aucun annuaire n'expose le morceau en cours : il faut ouvrir chaque flux. On s'en tient
// donc aux premières stations affichées, quatre à la fois, et le résultat est conservé.
LaunchedEffect(stations) {
nowPlaying = emptyMap()
stations.take(PROBED_STATIONS).chunked(4).forEach { group ->
val found = group.map { station ->
async { station.id to container.radio.nowPlaying(station.streamUrl) }
}.awaitAll()
nowPlaying = nowPlaying + found.mapNotNull { (id, title) ->
title?.let { id to it }
}
}
}
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
@@ -134,6 +155,7 @@ fun RadioScreen(
RecordingBanner(
station = status.station!!,
title = status.currentTitle,
waitingFor = status.waitingFor,
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
bytes = status.bytes,
onStop = {
@@ -206,8 +228,10 @@ fun RadioScreen(
items(stations, key = { it.id }) { station ->
StationRow(
station = station,
nowPlaying = nowPlaying[station.id],
playing = station.asEntry().id == currentMediaId,
recording = status.station?.id == station.id,
onSchedule = { scheduleFor = station },
onListen = {
val entry = station.asEntry()
container.library.preview(listOf(entry))
@@ -227,8 +251,23 @@ fun RadioScreen(
}
}
}
scheduleFor?.let { station ->
ScheduleDialog(
station = station,
onDismiss = { scheduleFor = null },
onConfirm = { wanted ->
RecordingService.start(context, station, whenTitleContains = wanted)
onMessage("Cachette guette « $wanted » sur ${station.name}")
scheduleFor = null
},
)
}
}
/** Au-delà, le nombre de connexions simultanées ne se justifierait plus. */
private const val PROBED_STATIONS = 12
/** Une station écoutée en direct est un média éphémère : pas de durée, pas de mise en cache. */
private fun RadioStation.asEntry() = MediaEntry(
id = "radio:$id",
@@ -240,10 +279,47 @@ private fun RadioStation.asEntry() = MediaEntry(
artworkUri = faviconUrl,
)
/** Programme un enregistrement : il démarrera dès que la station annoncera ce titre. */
@Composable
private fun ScheduleDialog(
station: RadioStation,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var wanted by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Guetter un titre") },
text = {
Column {
Text(
text = "Cachette écoutera ${station.name} et déclenchera l'enregistrement dès " +
"qu'un titre contenant ce texte sera annoncé, puis s'arrêtera à la fin du morceau.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = wanted,
onValueChange = { wanted = it },
placeholder = { Text("Titre ou artiste") },
singleLine = true,
)
}
},
confirmButton = {
TextButton(enabled = wanted.isNotBlank(), onClick = { onConfirm(wanted.trim()) }) {
Text("Guetter")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
)
}
@Composable
private fun RecordingBanner(
station: RadioStation,
title: String?,
waitingFor: String?,
elapsedMs: Long,
bytes: Long,
onStop: () -> Unit,
@@ -267,7 +343,7 @@ private fun RecordingBanner(
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = title ?: station.name,
text = waitingFor?.let { "En attente de « $it »" } ?: title ?: station.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -295,10 +371,12 @@ private fun RecordingBanner(
@Composable
private fun StationRow(
station: RadioStation,
nowPlaying: String?,
playing: Boolean,
recording: Boolean,
onListen: () -> Unit,
onRecord: () -> Unit,
onSchedule: () -> Unit,
) {
Surface(
color = if (playing) MaterialTheme.colorScheme.primaryContainer
@@ -328,9 +406,10 @@ private fun StationRow(
overflow = TextOverflow.Ellipsis,
)
Text(
text = station.subtitle,
text = nowPlaying ?: station.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = if (nowPlaying != null) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -341,6 +420,13 @@ private fun StationRow(
tint = if (playing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(onClick = onSchedule) {
Icon(
Icons.Rounded.Alarm,
contentDescription = "Enregistrer quand un titre passe",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRecord) {
Icon(
Icons.Rounded.FiberManualRecord,
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.MenuBook
import androidx.compose.material.icons.rounded.Headphones
import androidx.compose.material.icons.rounded.LibraryMusic
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Radio
@@ -152,6 +153,7 @@ fun SourcesScreen(
icon = when (catalog) {
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
CatalogId.UNDERGROUND -> Icons.Rounded.Headphones
else -> Icons.Rounded.Public
},
title = catalog.title,