diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ffb2c7..cac3c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/README.md b/README.md index 3be5447..263c857 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.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. diff --git a/VERSION b/VERSION index 88c5fb8..bc80560 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt b/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt index 4c84308..7689e52 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt @@ -48,6 +48,10 @@ class AppContainer(context: Context) { val catalogs: Map = listOf( ArchiveOrgCatalog(), + ArchiveOrgCatalog( + id = CatalogId.UNDERGROUND, + collections = listOf("netlabels", "freemusicarchive"), + ), LibriVoxCatalog(rssImporter), WikimediaCatalog(), ).associateBy { it.id } diff --git a/app/src/main/java/fr/tuxgyver/cachette/library/LocalMediaScanner.kt b/app/src/main/java/fr/tuxgyver/cachette/library/LocalMediaScanner.kt index b03640d..52148b3 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/library/LocalMediaScanner.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/library/LocalMediaScanner.kt @@ -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) diff --git a/app/src/main/java/fr/tuxgyver/cachette/radio/RadioDirectory.kt b/app/src/main/java/fr/tuxgyver/cachette/radio/RadioDirectory.kt index 8d143d1..fbd48ca 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/radio/RadioDirectory.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/RadioDirectory.kt @@ -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> = 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()) 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 77744da..57ceb8b 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt @@ -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 = _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) { 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 2b392e9..a6226b8 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt @@ -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) } diff --git a/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingSink.kt b/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingSink.kt new file mode 100644 index 0000000..64dcd68 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingSink.kt @@ -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? { + 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) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt index ba5af08..5860759 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt @@ -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 = 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", ) diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt index 1e360bb..571679a 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt @@ -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.", 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 d2b12e1..c73a047 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 @@ -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(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 }, 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 b865e8c..7e16036 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 @@ -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>(emptyList()) } var loading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } + var nowPlaying by remember { mutableStateOf(emptyMap()) } + var scheduleFor by remember { mutableStateOf(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, diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt index 3f5f095..4da533b 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt @@ -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,