Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9da43366b |
No files matched your search
@@ -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/)
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
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
|
## [1.4.0] — 2026-09-13
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
[](https://developer.android.com/jetpack/compose)
|
[](https://developer.android.com/jetpack/compose)
|
||||||
[](https://developer.android.com/media/media3)
|
[](https://developer.android.com/media/media3)
|
||||||
[](VERSION)
|
[](VERSION)
|
||||||
[](#-comment-ça-marche)
|
[](#-comment-ça-marche)
|
||||||
|
|
||||||
Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
|
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 |
|
| 🔁 **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 |
|
| 📻 **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 |
|
| 🏷️ **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 |
|
| 🆕 **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 |
|
| 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue |
|
||||||
| 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres |
|
| 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres |
|
||||||
| 📻 Radio | ✅ | Annuaire Radio Browser, direct et enregistrement (flux HLS exclus) |
|
| 📻 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
|
### 🔎 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
|
## 🆕 Mises à jour
|
||||||
|
|
||||||
Cachette étant distribuée hors magasin, elle surveille elle-même les releases du dépôt.
|
Cachette étant distribuée hors magasin, elle surveille elle-même les releases du dépôt.
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val catalogs: Map<CatalogId, Catalog> = listOf(
|
val catalogs: Map<CatalogId, Catalog> = listOf(
|
||||||
ArchiveOrgCatalog(),
|
ArchiveOrgCatalog(),
|
||||||
|
ArchiveOrgCatalog(
|
||||||
|
id = CatalogId.UNDERGROUND,
|
||||||
|
collections = listOf("netlabels", "freemusicarchive"),
|
||||||
|
),
|
||||||
LibriVoxCatalog(rssImporter),
|
LibriVoxCatalog(rssImporter),
|
||||||
WikimediaCatalog(),
|
WikimediaCatalog(),
|
||||||
).associateBy { it.id }
|
).associateBy { it.id }
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class LocalMediaScanner(private val context: Context) {
|
|||||||
context.contentResolver.query(
|
context.contentResolver.query(
|
||||||
collection,
|
collection,
|
||||||
projection,
|
projection,
|
||||||
"${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0",
|
audioSelection(),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
)?.use { cursor ->
|
)?.use { cursor ->
|
||||||
@@ -123,6 +123,19 @@ class LocalMediaScanner(private val context: Context) {
|
|||||||
return entries
|
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 =
|
private fun albumArtUri(albumId: Long): Uri =
|
||||||
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
|
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,49 @@ class RadioDirectory {
|
|||||||
query("?limit=60&hidebroken=true&order=clickcount&reverse=true" +
|
query("?limit=60&hidebroken=true&order=clickcount&reverse=true" +
|
||||||
"&name=${URLEncoder.encode(name.trim(), "UTF-8")}")
|
"&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) {
|
private suspend fun query(suffix: String): Result<List<RadioStation>> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000)
|
val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000)
|
||||||
@@ -66,6 +109,9 @@ class RadioDirectory {
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val BASE = "https://de1.api.radio-browser.info/json/stations/search"
|
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. */
|
/** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */
|
||||||
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
|
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ data class RecordingStatus(
|
|||||||
val bytes: Long = 0L,
|
val bytes: Long = 0L,
|
||||||
/** Titre annoncé par le flux, quand la station en diffuse un. */
|
/** Titre annoncé par le flux, quand la station en diffuse un. */
|
||||||
val currentTitle: String? = null,
|
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 isRecording: Boolean get() = station != null
|
||||||
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
|
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
|
||||||
@@ -25,8 +27,17 @@ object Recorder {
|
|||||||
private val _status = MutableStateFlow(RecordingStatus())
|
private val _status = MutableStateFlow(RecordingStatus())
|
||||||
val status: StateFlow<RecordingStatus> = _status.asStateFlow()
|
val status: StateFlow<RecordingStatus> = _status.asStateFlow()
|
||||||
|
|
||||||
internal fun started(station: RadioStation) {
|
internal fun started(station: RadioStation, waitingFor: String? = null) {
|
||||||
_status.value = RecordingStatus(station, System.currentTimeMillis(), 0L)
|
_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) {
|
internal fun progress(bytes: Long) {
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ class RecordingService : Service() {
|
|||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var job: Job? = null
|
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 station: RadioStation? = null
|
||||||
private var firstTitle: String? = null
|
private var firstTitle: String? = null
|
||||||
|
private var trigger: String? = null
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ class RecordingService : Service() {
|
|||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
if (job?.isActive == true) return START_STICKY
|
if (job?.isActive == true) return START_STICKY
|
||||||
|
trigger = intent.getStringExtra(EXTRA_TRIGGER)?.takeIf { it.isNotBlank() }
|
||||||
begin(incoming)
|
begin(incoming)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,13 +75,18 @@ class RecordingService : Service() {
|
|||||||
private fun begin(incoming: RadioStation) {
|
private fun begin(incoming: RadioStation) {
|
||||||
station = incoming
|
station = incoming
|
||||||
firstTitle = null
|
firstTitle = null
|
||||||
Recorder.started(incoming)
|
Recorder.started(incoming, trigger)
|
||||||
startForegroundCompat(notification(incoming, 0L))
|
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 stamp = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.FRANCE).format(Date())
|
||||||
val file = File(directory, "${incoming.name.sanitized()}-$stamp.${incoming.extension()}")
|
val extension = incoming.extension()
|
||||||
target = file
|
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 {
|
job = scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -93,13 +102,18 @@ class RecordingService : Service() {
|
|||||||
}
|
}
|
||||||
val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0)
|
val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0)
|
||||||
connection.inputStream.use { input ->
|
connection.inputStream.use { input ->
|
||||||
FileOutputStream(file).use { output ->
|
output.open().use { stream ->
|
||||||
if (metaInterval > 0) {
|
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 {
|
} else {
|
||||||
copyPlain(input, output, incoming)
|
copyPlain(input, stream, incoming)
|
||||||
}
|
}
|
||||||
output.flush()
|
stream.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,7 +128,7 @@ class RecordingService : Service() {
|
|||||||
/** Flux sans métadonnées : simple recopie. */
|
/** Flux sans métadonnées : simple recopie. */
|
||||||
private suspend fun copyPlain(
|
private suspend fun copyPlain(
|
||||||
input: java.io.InputStream,
|
input: java.io.InputStream,
|
||||||
output: FileOutputStream,
|
output: java.io.OutputStream,
|
||||||
station: RadioStation,
|
station: RadioStation,
|
||||||
) {
|
) {
|
||||||
val buffer = ByteArray(32 * 1024)
|
val buffer = ByteArray(32 * 1024)
|
||||||
@@ -140,18 +154,24 @@ class RecordingService : Service() {
|
|||||||
*/
|
*/
|
||||||
private suspend fun copyWithTitles(
|
private suspend fun copyWithTitles(
|
||||||
input: java.io.InputStream,
|
input: java.io.InputStream,
|
||||||
output: FileOutputStream,
|
output: java.io.OutputStream,
|
||||||
interval: Int,
|
interval: Int,
|
||||||
station: RadioStation,
|
station: RadioStation,
|
||||||
) {
|
) {
|
||||||
|
val wanted = trigger
|
||||||
val audio = ByteArray(interval)
|
val audio = ByteArray(interval)
|
||||||
var total = 0L
|
var total = 0L
|
||||||
var lastNotified = 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) {
|
while (currentCoroutineContext().isActive) {
|
||||||
if (!input.readFully(audio, interval)) break
|
if (!input.readFully(audio, interval)) break
|
||||||
output.write(audio)
|
if (writing) {
|
||||||
total += interval
|
output.write(audio)
|
||||||
|
total += interval
|
||||||
|
}
|
||||||
|
|
||||||
val lengthByte = input.read()
|
val lengthByte = input.read()
|
||||||
if (lengthByte < 0) break
|
if (lengthByte < 0) break
|
||||||
@@ -159,16 +179,32 @@ class RecordingService : Service() {
|
|||||||
if (metaLength > 0) {
|
if (metaLength > 0) {
|
||||||
val meta = ByteArray(metaLength)
|
val meta = ByteArray(metaLength)
|
||||||
if (!input.readFully(meta, metaLength)) break
|
if (!input.readFully(meta, metaLength)) break
|
||||||
STREAM_TITLE
|
val announced = STREAM_TITLE
|
||||||
.find(String(meta, Charsets.UTF_8))
|
.find(String(meta, Charsets.UTF_8))
|
||||||
?.groupValues?.get(1)
|
?.groupValues?.get(1)
|
||||||
?.trim()
|
?.trim()
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
?.let { title ->
|
|
||||||
if (firstTitle == null) firstTitle = title
|
if (announced != null) {
|
||||||
Recorder.title(title)
|
Recorder.title(announced)
|
||||||
notifyProgress(station, total)
|
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) {
|
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
|
||||||
@@ -196,34 +232,38 @@ class RecordingService : Service() {
|
|||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = null
|
job = null
|
||||||
|
|
||||||
val file = target
|
val output = sink
|
||||||
val recorded = station
|
val recorded = station
|
||||||
target = null
|
|
||||||
station = null
|
|
||||||
val captured = firstTitle
|
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 elapsed = Recorder.status.value.elapsedMs
|
||||||
val container = (application as CachetteApp).container
|
// Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant.
|
||||||
val entry = MediaEntry(
|
val title = captured ?: sinkTitle
|
||||||
id = "rec:${file.name}",
|
val published = output.finish(title, elapsed)
|
||||||
source = SourceKind.RECORDING,
|
if (published != null) {
|
||||||
kind = MediaKind.AUDIO,
|
val (uri, size) = published
|
||||||
// Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant.
|
val container = (application as CachetteApp).container
|
||||||
title = captured
|
val entry = MediaEntry(
|
||||||
?: "${recorded.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}",
|
id = "rec:${uri.hashCode()}",
|
||||||
subtitle = if (captured != null) recorded.name else "Enregistrement radio",
|
source = SourceKind.RECORDING,
|
||||||
uri = file.toURI().toString(),
|
kind = MediaKind.AUDIO,
|
||||||
artworkUri = recorded.faviconUrl,
|
title = title,
|
||||||
durationMs = elapsed,
|
subtitle = if (captured != null) recorded.name else "Enregistrement radio",
|
||||||
addedAt = System.currentTimeMillis(),
|
uri = uri,
|
||||||
sizeBytes = file.length(),
|
artworkUri = recorded.faviconUrl,
|
||||||
)
|
durationMs = elapsed,
|
||||||
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) }
|
addedAt = System.currentTimeMillis(),
|
||||||
} else {
|
sizeBytes = size,
|
||||||
file?.delete()
|
)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
firstTitle = null
|
||||||
Recorder.stopped()
|
Recorder.stopped()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
@@ -267,7 +307,10 @@ class RecordingService : Service() {
|
|||||||
)
|
)
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_offline)
|
.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(
|
.setContentText(
|
||||||
Recorder.status.value.currentTitle
|
Recorder.status.value.currentTitle
|
||||||
?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…"
|
?: 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_CODEC = "codec"
|
||||||
private const val EXTRA_BITRATE = "bitrate"
|
private const val EXTRA_BITRATE = "bitrate"
|
||||||
private const val EXTRA_FAVICON = "favicon"
|
private const val EXTRA_FAVICON = "favicon"
|
||||||
|
private const val EXTRA_TRIGGER = "trigger"
|
||||||
private const val NOTIFICATION_ID = 4401
|
private const val NOTIFICATION_ID = 4401
|
||||||
private const val CHANNEL_ID = "cachette_recording"
|
private const val CHANNEL_ID = "cachette_recording"
|
||||||
private const val NOTIFY_EVERY_BYTES = 256 * 1024L
|
private const val NOTIFY_EVERY_BYTES = 256 * 1024L
|
||||||
private const val MIN_USEFUL_BYTES = 16 * 1024L
|
private const val MIN_USEFUL_BYTES = 16 * 1024L
|
||||||
private val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
|
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)
|
val intent = Intent(context, RecordingService::class.java)
|
||||||
.setAction(ACTION_START)
|
.setAction(ACTION_START)
|
||||||
.putExtra(EXTRA_ID, station.id)
|
.putExtra(EXTRA_ID, station.id)
|
||||||
@@ -323,6 +367,7 @@ class RecordingService : Service() {
|
|||||||
.putExtra(EXTRA_CODEC, station.codec)
|
.putExtra(EXTRA_CODEC, station.codec)
|
||||||
.putExtra(EXTRA_BITRATE, station.bitrate)
|
.putExtra(EXTRA_BITRATE, station.bitrate)
|
||||||
.putExtra(EXTRA_FAVICON, station.faviconUrl)
|
.putExtra(EXTRA_FAVICON, station.faviconUrl)
|
||||||
|
.putExtra(EXTRA_TRIGGER, whenTitleContains)
|
||||||
context.startForegroundService(intent)
|
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
|
* 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.
|
* trente éléments dont vingt-neuf seront ignorés.
|
||||||
*/
|
*/
|
||||||
class ArchiveOrgCatalog : Catalog {
|
class ArchiveOrgCatalog(
|
||||||
|
override val id: CatalogId = CatalogId.ARCHIVE,
|
||||||
override val id = 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
|
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
|
// 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.
|
// dès la recherche plutôt que de les proposer puis d'échouer.
|
||||||
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
|
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(
|
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",
|
" AND NOT access-restricted-item:true",
|
||||||
"UTF-8",
|
"UTF-8",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ enum class CatalogId(
|
|||||||
license = "Domaine public",
|
license = "Domaine public",
|
||||||
suggestion = "Jules Verne",
|
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(
|
WIKIMEDIA(
|
||||||
title = "Wikimedia Commons",
|
title = "Wikimedia Commons",
|
||||||
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.CloudOff
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
|
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -27,18 +29,22 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.cachette.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
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.MediaRow
|
||||||
import fr.tuxgyver.cachette.ui.components.formatBytes
|
import fr.tuxgyver.cachette.ui.components.formatBytes
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -52,6 +58,9 @@ fun DownloadsScreen(
|
|||||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||||
var confirmClear by remember { mutableStateOf(false) }
|
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) }
|
var cacheSize by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
// Un enregistrement radio n'a pas d'état de téléchargement : le fichier est déjà là.
|
// 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 ->
|
items(cached, key = { it.id }) { entry ->
|
||||||
val state = offlineStates[entry.id]
|
val state = offlineStates[entry.id]
|
||||||
Column {
|
Column {
|
||||||
|
val isRecording = entry.source == SourceKind.RECORDING
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
MediaRow(
|
MediaRow(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
offline = state,
|
offline = state,
|
||||||
@@ -109,7 +120,17 @@ fun DownloadsScreen(
|
|||||||
onDownload = { container.downloads.download(entry) },
|
onDownload = { container.downloads.download(entry) },
|
||||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
onLongClick = { onAddToPlaylist(entry) },
|
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) {
|
if (state?.isActive == true) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (state.percent / 100f).coerceIn(0f, 1f) },
|
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) {
|
if (confirmClear) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmClear = false },
|
onDismissRequest = { confirmClear = false },
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
import androidx.compose.material.icons.rounded.FiberManualRecord
|
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.GraphicEq
|
||||||
import androidx.compose.material.icons.rounded.PlayCircleOutline
|
import androidx.compose.material.icons.rounded.PlayCircleOutline
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Stop
|
import androidx.compose.material.icons.rounded.Stop
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
@@ -36,6 +38,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.formatBytes
|
||||||
import fr.tuxgyver.cachette.ui.components.formatDuration
|
import fr.tuxgyver.cachette.ui.components.formatDuration
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +97,8 @@ fun RadioScreen(
|
|||||||
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
|
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var nowPlaying by remember { mutableStateOf(emptyMap<String, String>()) }
|
||||||
|
var scheduleFor by remember { mutableStateOf<RadioStation?>(null) }
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
loading = true
|
loading = true
|
||||||
@@ -109,6 +116,20 @@ fun RadioScreen(
|
|||||||
|
|
||||||
LaunchedEffect(genre) { load() }
|
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())
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
@@ -134,6 +155,7 @@ fun RadioScreen(
|
|||||||
RecordingBanner(
|
RecordingBanner(
|
||||||
station = status.station!!,
|
station = status.station!!,
|
||||||
title = status.currentTitle,
|
title = status.currentTitle,
|
||||||
|
waitingFor = status.waitingFor,
|
||||||
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
|
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
|
||||||
bytes = status.bytes,
|
bytes = status.bytes,
|
||||||
onStop = {
|
onStop = {
|
||||||
@@ -206,8 +228,10 @@ fun RadioScreen(
|
|||||||
items(stations, key = { it.id }) { station ->
|
items(stations, key = { it.id }) { station ->
|
||||||
StationRow(
|
StationRow(
|
||||||
station = station,
|
station = station,
|
||||||
|
nowPlaying = nowPlaying[station.id],
|
||||||
playing = station.asEntry().id == currentMediaId,
|
playing = station.asEntry().id == currentMediaId,
|
||||||
recording = status.station?.id == station.id,
|
recording = status.station?.id == station.id,
|
||||||
|
onSchedule = { scheduleFor = station },
|
||||||
onListen = {
|
onListen = {
|
||||||
val entry = station.asEntry()
|
val entry = station.asEntry()
|
||||||
container.library.preview(listOf(entry))
|
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. */
|
/** 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(
|
private fun RadioStation.asEntry() = MediaEntry(
|
||||||
id = "radio:$id",
|
id = "radio:$id",
|
||||||
@@ -240,10 +279,47 @@ private fun RadioStation.asEntry() = MediaEntry(
|
|||||||
artworkUri = faviconUrl,
|
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
|
@Composable
|
||||||
private fun RecordingBanner(
|
private fun RecordingBanner(
|
||||||
station: RadioStation,
|
station: RadioStation,
|
||||||
title: String?,
|
title: String?,
|
||||||
|
waitingFor: String?,
|
||||||
elapsedMs: Long,
|
elapsedMs: Long,
|
||||||
bytes: Long,
|
bytes: Long,
|
||||||
onStop: () -> Unit,
|
onStop: () -> Unit,
|
||||||
@@ -267,7 +343,7 @@ private fun RecordingBanner(
|
|||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = title ?: station.name,
|
text = waitingFor?.let { "En attente de « $it »" } ?: title ?: station.name,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -295,10 +371,12 @@ private fun RecordingBanner(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun StationRow(
|
private fun StationRow(
|
||||||
station: RadioStation,
|
station: RadioStation,
|
||||||
|
nowPlaying: String?,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
recording: Boolean,
|
recording: Boolean,
|
||||||
onListen: () -> Unit,
|
onListen: () -> Unit,
|
||||||
onRecord: () -> Unit,
|
onRecord: () -> Unit,
|
||||||
|
onSchedule: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = if (playing) MaterialTheme.colorScheme.primaryContainer
|
color = if (playing) MaterialTheme.colorScheme.primaryContainer
|
||||||
@@ -328,9 +406,10 @@ private fun StationRow(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = station.subtitle,
|
text = nowPlaying ?: station.subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (nowPlaying != null) MaterialTheme.colorScheme.secondary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -341,6 +420,13 @@ private fun StationRow(
|
|||||||
tint = if (playing) MaterialTheme.colorScheme.primary
|
tint = if (playing) MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = onSchedule) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Alarm,
|
||||||
|
contentDescription = "Enregistrer quand un titre passe",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = onRecord) {
|
IconButton(onClick = onRecord) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Rounded.FiberManualRecord,
|
Icons.Rounded.FiberManualRecord,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
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.LibraryMusic
|
||||||
import androidx.compose.material.icons.rounded.Public
|
import androidx.compose.material.icons.rounded.Public
|
||||||
import androidx.compose.material.icons.rounded.Radio
|
import androidx.compose.material.icons.rounded.Radio
|
||||||
@@ -152,6 +153,7 @@ fun SourcesScreen(
|
|||||||
icon = when (catalog) {
|
icon = when (catalog) {
|
||||||
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
|
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
|
||||||
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
|
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
|
||||||
|
CatalogId.UNDERGROUND -> Icons.Rounded.Headphones
|
||||||
else -> Icons.Rounded.Public
|
else -> Icons.Rounded.Public
|
||||||
},
|
},
|
||||||
title = catalog.title,
|
title = catalog.title,
|
||||||
|
|||||||
Reference in new issue
Block a user