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:
1 parent
b96354c40f
commit
b9da43366b
14 files changed
+482
-54
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user