Radio en direct et à l'enregistrement, téléchargements qui se relancent seuls (1.3.0)
Un téléchargement qui échoue restait échoué, attendant un geste. Il repart maintenant de lui-même après cinq, quinze puis quarante-cinq secondes. Vérifié contre un serveur qui refuse deux fois avant d'accepter : trois requêtes, et le fichier arrive sans intervention. Cela ne recouvre pas la coupure réseau, et c'est voulu : sans réseau, Media3 met déjà le téléchargement en attente et le reprend au retour. Les deux mécanismes se complètent au lieu de se concurrencer. La radio manquait. L'annuaire Radio Browser fournit les stations les plus écoutées, par genre ou par nom ; chacune s'écoute en direct ou s'enregistre. Un direct n'ayant pas de fin, DownloadManager ne pouvait pas s'en charger : un service de premier plan copie le flux jusqu'à l'arrêt demandé, puis verse le fichier dans la bibliothèque, déjà hors connexion. Les flux HLS sont écartés de la liste, ne se laissant pas enregistrer par copie d'octets. Le repli vers une autre source en cas d'échec n'a pas été retenu : rien ne garantit qu'un même titre trouvé sur deux catalogues soit le même enregistrement.
This commit is contained in:
1 parent
2dabfdbc84
commit
4017c71ca7
14 files changed
+854
-10
No files matched your search
@@ -5,6 +5,28 @@ 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.3.0] — 2026-09-13
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Radio.** Les stations les plus écoutées via l'annuaire communautaire Radio Browser, avec
|
||||||
|
filtres par genre et recherche par nom. Chaque station s'écoute en direct, ou s'enregistre :
|
||||||
|
un service de premier plan copie le flux dans un fichier jusqu'à l'arrêt demandé, et le verse
|
||||||
|
dans la bibliothèque — déjà hors connexion, sans rien à télécharger ensuite.
|
||||||
|
- Relance automatique des téléchargements en échec, après une pause croissante de 5, 15 puis
|
||||||
|
45 secondes. Une relance manuelle repart d'un compteur neuf.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Les flux HLS sont écartés de la liste des stations : ils se lisent, mais ne s'enregistrent pas
|
||||||
|
par copie d'octets, et un bouton qui échouerait vaudrait moins que pas de bouton.
|
||||||
|
- La relance et la reprise sur coupure réseau sont deux mécanismes distincts, et c'est voulu :
|
||||||
|
sans réseau, Media3 met le téléchargement en attente et le reprend de lui-même ; la relance,
|
||||||
|
elle, ne traite que les vrais échecs serveur.
|
||||||
|
- Le repli automatique vers une autre source n'a pas été retenu : rien ne garantit qu'un même
|
||||||
|
titre trouvé sur deux catalogues soit le même enregistrement, et l'application téléchargerait
|
||||||
|
silencieusement autre chose que ce qui a été choisi.
|
||||||
|
|
||||||
## [1.2.0] — 2026-09-13
|
## [1.2.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
|
||||||
@@ -41,7 +41,8 @@ sans la moindre connexion.
|
|||||||
| 🎛️ **Lecture en arrière-plan** | Notification système, casque, Bluetooth, écran verrouillé |
|
| 🎛️ **Lecture en arrière-plan** | Notification système, casque, Bluetooth, écran verrouillé |
|
||||||
| ⏯️ **Contrôles complets** | Lecture, pause, arrêt, piste précédente et suivante, ±15 s |
|
| ⏯️ **Contrôles complets** | Lecture, pause, arrêt, piste précédente et suivante, ±15 s |
|
||||||
| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti |
|
| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti |
|
||||||
| 🔁 **Reprise automatique** | Un téléchargement coupé repart tout seul via WorkManager |
|
| 🔁 **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 |
|
||||||
| 🆕 **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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +57,7 @@ sans la moindre connexion.
|
|||||||
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
|
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
|
||||||
| 📚 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) |
|
||||||
|
|
||||||
### 🔎 Catalogues évalués puis écartés
|
### 🔎 Catalogues évalués puis écartés
|
||||||
|
|
||||||
@@ -230,6 +232,7 @@ app/src/main/java/fr/tuxgyver/cachette/
|
|||||||
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
|
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
|
||||||
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
||||||
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
|
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
|
||||||
|
├── radio/ Annuaire des stations, service d'enregistrement du direct
|
||||||
├── update/ Vérification des releases, téléchargement, notification
|
├── update/ Vérification des releases, téléchargement, notification
|
||||||
└── ui/ Compose — thème, bibliothèque, hors ligne, playlists, sources,
|
└── ui/ Compose — thème, bibliothèque, hors ligne, playlists, sources,
|
||||||
parcours des catalogues, choix des pistes, lecteur
|
parcours des catalogues, choix des pistes, lecteur
|
||||||
|
|||||||
@@ -59,6 +59,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".radio.RecordingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import fr.tuxgyver.cachette.library.PlaylistStore
|
|||||||
import fr.tuxgyver.cachette.offline.DownloadTracker
|
import fr.tuxgyver.cachette.offline.DownloadTracker
|
||||||
import fr.tuxgyver.cachette.offline.OfflineModule
|
import fr.tuxgyver.cachette.offline.OfflineModule
|
||||||
import fr.tuxgyver.cachette.playback.PlayerController
|
import fr.tuxgyver.cachette.playback.PlayerController
|
||||||
|
import fr.tuxgyver.cachette.radio.RadioDirectory
|
||||||
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
|
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
|
||||||
import fr.tuxgyver.cachette.sources.Catalog
|
import fr.tuxgyver.cachette.sources.Catalog
|
||||||
import fr.tuxgyver.cachette.sources.CatalogId
|
import fr.tuxgyver.cachette.sources.CatalogId
|
||||||
@@ -40,6 +41,8 @@ class AppContainer(context: Context) {
|
|||||||
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||||
val playlists = PlaylistStore(context, scope)
|
val playlists = PlaylistStore(context, scope)
|
||||||
|
|
||||||
|
val radio = RadioDirectory()
|
||||||
|
|
||||||
val urlImporter = UrlImporter()
|
val urlImporter = UrlImporter()
|
||||||
val rssImporter = RssImporter()
|
val rssImporter = RssImporter()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ enum class SourceKind(val label: String) {
|
|||||||
LOCAL("Appareil"),
|
LOCAL("Appareil"),
|
||||||
URL("Lien direct"),
|
URL("Lien direct"),
|
||||||
PODCAST("Podcast"),
|
PODCAST("Podcast"),
|
||||||
|
RECORDING("Enregistrement"),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +32,6 @@ data class MediaEntry(
|
|||||||
/** Taille du fichier quand la source l'annonce, pour choisir avant de télécharger. */
|
/** Taille du fichier quand la source l'annonce, pour choisir avant de télécharger. */
|
||||||
val sizeBytes: Long? = null,
|
val sizeBytes: Long? = null,
|
||||||
) {
|
) {
|
||||||
/** Un média local est déjà disponible hors connexion : il n'a rien à télécharger. */
|
/** Local ou enregistré : le fichier est déjà sur l'appareil, il n'y a rien à télécharger. */
|
||||||
val isRemote: Boolean get() = source != SourceKind.LOCAL
|
val isRemote: Boolean get() = source != SourceKind.LOCAL && source != SourceKind.RECORDING
|
||||||
}
|
}
|
||||||
@@ -28,13 +28,15 @@ data class OfflineState(
|
|||||||
val state: Int,
|
val state: Int,
|
||||||
val percent: Float,
|
val percent: Float,
|
||||||
val bytesDownloaded: Long,
|
val bytesDownloaded: Long,
|
||||||
|
/** Une relance automatique est programmée : l'échec n'est pas définitif. */
|
||||||
|
val retrying: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isComplete: Boolean get() = state == Download.STATE_COMPLETED
|
val isComplete: Boolean get() = state == Download.STATE_COMPLETED
|
||||||
val isFailed: Boolean get() = state == Download.STATE_FAILED
|
val isFailed: Boolean get() = state == Download.STATE_FAILED && !retrying
|
||||||
val isPending: Boolean
|
val isPending: Boolean
|
||||||
get() = state == Download.STATE_QUEUED || state == Download.STATE_RESTARTING
|
get() = state == Download.STATE_QUEUED || state == Download.STATE_RESTARTING
|
||||||
val isRunning: Boolean get() = state == Download.STATE_DOWNLOADING
|
val isRunning: Boolean get() = state == Download.STATE_DOWNLOADING
|
||||||
val isActive: Boolean get() = isPending || isRunning
|
val isActive: Boolean get() = isPending || isRunning || retrying
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,10 +50,13 @@ data class OfflineState(
|
|||||||
class DownloadTracker(
|
class DownloadTracker(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val indexed = MutableStateFlow<Map<String, OfflineState>>(emptyMap())
|
private val indexed = MutableStateFlow<Map<String, OfflineState>>(emptyMap())
|
||||||
|
|
||||||
|
/** Identifiants dont une relance est programmée. */
|
||||||
|
private val retrying = MutableStateFlow(emptySet<String>())
|
||||||
|
|
||||||
private val ticker = flow {
|
private val ticker = flow {
|
||||||
while (true) {
|
while (true) {
|
||||||
emit(Unit)
|
emit(Unit)
|
||||||
@@ -60,20 +65,58 @@ class DownloadTracker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val states: StateFlow<Map<String, OfflineState>> =
|
val states: StateFlow<Map<String, OfflineState>> =
|
||||||
indexed.combine(ticker) { snapshot, _ ->
|
combine(indexed, ticker, retrying) { snapshot, _, pending ->
|
||||||
snapshot + downloadManager.currentDownloads.associate { it.request.id to it.toOfflineState() }
|
val live = snapshot +
|
||||||
|
downloadManager.currentDownloads.associate { it.request.id to it.toOfflineState() }
|
||||||
|
live.mapValues { (id, state) -> state.copy(retrying = id in pending) }
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tentatives déjà consommées par téléchargement.
|
||||||
|
*
|
||||||
|
* Un serveur qui coupe, un réseau qui vacille : l'échec est le plus souvent passager. On
|
||||||
|
* relance donc seul, en espaçant les essais, plutôt que d'attendre un geste de l'utilisateur.
|
||||||
|
* Le compteur vit en mémoire — au redémarrage, WorkManager reprend les téléchargements et le
|
||||||
|
* décompte repart, ce qui est le comportement voulu.
|
||||||
|
*/
|
||||||
|
private val attempts = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
private val listener = object : DownloadManager.Listener {
|
private val listener = object : DownloadManager.Listener {
|
||||||
override fun onDownloadChanged(manager: DownloadManager, download: Download, finalException: Exception?) {
|
override fun onDownloadChanged(manager: DownloadManager, download: Download, finalException: Exception?) {
|
||||||
|
when (download.state) {
|
||||||
|
Download.STATE_COMPLETED -> attempts.remove(download.request.id)
|
||||||
|
Download.STATE_FAILED -> scheduleRetry(download)
|
||||||
|
}
|
||||||
scope.launch { reload() }
|
scope.launch { reload() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||||
|
attempts.remove(download.request.id)
|
||||||
scope.launch { reload() }
|
scope.launch { reload() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Relance après une pause croissante : 5 s, 15 s, puis 45 s. Au-delà, l'échec est signalé. */
|
||||||
|
private fun scheduleRetry(download: Download) {
|
||||||
|
val id = download.request.id
|
||||||
|
val used = attempts.getOrDefault(id, 0)
|
||||||
|
if (used >= RETRY_DELAYS_MS.size) return
|
||||||
|
|
||||||
|
attempts[id] = used + 1
|
||||||
|
retrying.value = retrying.value + id
|
||||||
|
scope.launch {
|
||||||
|
delay(RETRY_DELAYS_MS[used])
|
||||||
|
retrying.value = retrying.value - id
|
||||||
|
// Ré-ajouter la même requête relance un téléchargement en échec.
|
||||||
|
DownloadService.sendAddDownload(
|
||||||
|
context,
|
||||||
|
CachetteDownloadService::class.java,
|
||||||
|
download.request,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
downloadManager.addListener(listener)
|
downloadManager.addListener(listener)
|
||||||
scope.launch { reload() }
|
scope.launch { reload() }
|
||||||
@@ -81,6 +124,8 @@ class DownloadTracker(
|
|||||||
|
|
||||||
fun download(entry: MediaEntry) {
|
fun download(entry: MediaEntry) {
|
||||||
if (!entry.isRemote) return
|
if (!entry.isRemote) return
|
||||||
|
// Une relance manuelle repart d'un compteur neuf.
|
||||||
|
attempts.remove(entry.id)
|
||||||
val request = DownloadRequest.Builder(entry.id, entry.uri.toUri())
|
val request = DownloadRequest.Builder(entry.id, entry.uri.toUri())
|
||||||
.setData(entry.title.toByteArray())
|
.setData(entry.title.toByteArray())
|
||||||
.build()
|
.build()
|
||||||
@@ -108,6 +153,10 @@ class DownloadTracker(
|
|||||||
indexed.value = snapshot
|
indexed.value = snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val RETRY_DELAYS_MS = longArrayOf(5_000, 15_000, 45_000)
|
||||||
|
}
|
||||||
|
|
||||||
private fun Download.toOfflineState() = OfflineState(
|
private fun Download.toOfflineState() = OfflineState(
|
||||||
id = request.id,
|
id = request.id,
|
||||||
state = state,
|
state = state,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package fr.tuxgyver.cachette.radio
|
||||||
|
|
||||||
|
import fr.tuxgyver.cachette.sources.openConnection
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annuaire des stations, via l'API communautaire Radio Browser (sans clé).
|
||||||
|
*
|
||||||
|
* Les flux HLS sont écartés : ils se lisent, mais ne s'enregistrent pas en copiant les octets,
|
||||||
|
* et proposer un bouton qui échouerait vaudrait moins que ne pas le proposer.
|
||||||
|
*/
|
||||||
|
class RadioDirectory {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/** Les stations les plus écoutées, éventuellement restreintes à un genre. */
|
||||||
|
suspend fun popular(genre: String? = null): Result<List<RadioStation>> =
|
||||||
|
query(buildString {
|
||||||
|
append("?limit=60&hidebroken=true&order=clickcount&reverse=true")
|
||||||
|
genre?.let { append("&tag=${URLEncoder.encode(it, "UTF-8")}") }
|
||||||
|
})
|
||||||
|
|
||||||
|
suspend fun search(name: String): Result<List<RadioStation>> =
|
||||||
|
query("?limit=60&hidebroken=true&order=clickcount&reverse=true" +
|
||||||
|
"&name=${URLEncoder.encode(name.trim(), "UTF-8")}")
|
||||||
|
|
||||||
|
private suspend fun query(suffix: String): Result<List<RadioStation>> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000)
|
||||||
|
val body = try {
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("L'annuaire a répondu ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
connection.inputStream.bufferedReader().readText()
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
(json.parseToJsonElement(body) as? JsonArray).orEmpty()
|
||||||
|
.mapNotNull { it as? JsonObject }
|
||||||
|
.filter { it.number("hls") == 0L }
|
||||||
|
.mapNotNull { station ->
|
||||||
|
val url = station.string("url_resolved")?.takeIf { it.startsWith("http") }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
RadioStation(
|
||||||
|
id = station.string("stationuuid") ?: url,
|
||||||
|
name = station.string("name")?.trim() ?: "Station",
|
||||||
|
streamUrl = url,
|
||||||
|
codec = station.string("codec").orEmpty(),
|
||||||
|
bitrate = station.number("bitrate")?.toInt() ?: 0,
|
||||||
|
country = station.string("country").orEmpty(),
|
||||||
|
tags = station.string("tags").orEmpty(),
|
||||||
|
faviconUrl = station.string("favicon")?.takeIf { it.startsWith("http") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.distinctBy { it.streamUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "https://de1.api.radio-browser.info/json/stations/search"
|
||||||
|
|
||||||
|
/** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */
|
||||||
|
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
|
||||||
|
|
||||||
|
fun JsonObject.string(key: String): String? =
|
||||||
|
(this[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
|
||||||
|
|
||||||
|
fun JsonObject.number(key: String): Long? =
|
||||||
|
(this[key] as? JsonPrimitive)?.content?.substringBefore('.')?.toLongOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genres offerts en filtre rapide. */
|
||||||
|
val RADIO_GENRES = listOf("pop", "rock", "jazz", "classical", "electronic", "news", "chanson")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package fr.tuxgyver.cachette.radio
|
||||||
|
|
||||||
|
/** Une station de radio et son flux direct. */
|
||||||
|
data class RadioStation(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val streamUrl: String,
|
||||||
|
val codec: String,
|
||||||
|
val bitrate: Int,
|
||||||
|
val country: String,
|
||||||
|
val tags: String,
|
||||||
|
val faviconUrl: String?,
|
||||||
|
) {
|
||||||
|
val subtitle: String
|
||||||
|
get() = listOfNotNull(
|
||||||
|
country.takeIf { it.isNotBlank() },
|
||||||
|
tags.split(',').firstOrNull()?.trim()?.takeIf { it.isNotBlank() }?.replaceFirstChar(Char::uppercase),
|
||||||
|
bitrate.takeIf { it > 0 }?.let { "$it kbps" },
|
||||||
|
).joinToString(" · ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package fr.tuxgyver.cachette.radio
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
data class RecordingStatus(
|
||||||
|
val station: RadioStation? = null,
|
||||||
|
val startedAt: Long = 0L,
|
||||||
|
val bytes: Long = 0L,
|
||||||
|
) {
|
||||||
|
val isRecording: Boolean get() = station != null
|
||||||
|
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État de l'enregistrement en cours.
|
||||||
|
*
|
||||||
|
* Seul [RecordingService] y écrit ; l'interface ne fait que l'observer. Un singleton se justifie
|
||||||
|
* ici parce que l'enregistrement est unique par nature : on n'enregistre qu'une station à la fois.
|
||||||
|
*/
|
||||||
|
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 progress(bytes: Long) {
|
||||||
|
_status.value = _status.value.copy(bytes = bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun stopped() {
|
||||||
|
_status.value = RecordingStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package fr.tuxgyver.cachette.radio
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import fr.tuxgyver.cachette.CachetteApp
|
||||||
|
import fr.tuxgyver.cachette.MainActivity
|
||||||
|
import fr.tuxgyver.cachette.R
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import fr.tuxgyver.cachette.sources.openConnection
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre un flux radio en copiant ses octets dans un fichier.
|
||||||
|
*
|
||||||
|
* Un direct n'a pas de fin : `DownloadManager`, qui attend une taille et une complétion, ne peut
|
||||||
|
* pas s'en charger. L'enregistrement tourne donc en service de premier plan jusqu'à ce que
|
||||||
|
* l'utilisateur l'arrête, et le fichier obtenu rejoint la bibliothèque comme média local.
|
||||||
|
*/
|
||||||
|
class RecordingService : Service() {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var job: Job? = null
|
||||||
|
private var target: File? = null
|
||||||
|
private var station: RadioStation? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
finish()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_START -> {
|
||||||
|
val incoming = intent.toStation() ?: run {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
if (job?.isActive == true) return START_STICKY
|
||||||
|
begin(incoming)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun begin(incoming: RadioStation) {
|
||||||
|
station = incoming
|
||||||
|
Recorder.started(incoming)
|
||||||
|
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
|
||||||
|
|
||||||
|
job = scope.launch {
|
||||||
|
runCatching {
|
||||||
|
val connection = openConnection(incoming.streamUrl, readTimeoutMs = 30_000)
|
||||||
|
try {
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("Flux indisponible (${connection.responseCode})")
|
||||||
|
}
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
FileOutputStream(file).use { output ->
|
||||||
|
val buffer = ByteArray(32 * 1024)
|
||||||
|
var total = 0L
|
||||||
|
var lastNotified = 0L
|
||||||
|
while (isActive) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read <= 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
total += read
|
||||||
|
// Une notification par seconde suffit : le flux, lui, défile vite.
|
||||||
|
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
|
||||||
|
lastNotified = total
|
||||||
|
Recorder.progress(total)
|
||||||
|
notifyProgress(incoming, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
Recorder.progress(total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fin du flux ou coupure réseau : ce qui a été capté reste valable.
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clôt l'enregistrement et verse le fichier obtenu dans la bibliothèque. */
|
||||||
|
private fun finish() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
|
||||||
|
val file = target
|
||||||
|
val recorded = station
|
||||||
|
target = null
|
||||||
|
station = null
|
||||||
|
|
||||||
|
if (file != null && recorded != null && file.length() > MIN_USEFUL_BYTES) {
|
||||||
|
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,
|
||||||
|
title = "${recorded.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}",
|
||||||
|
subtitle = "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()
|
||||||
|
}
|
||||||
|
|
||||||
|
Recorder.stopped()
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundCompat(notification: Notification) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyProgress(station: RadioStation, bytes: Long) {
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.notify(NOTIFICATION_ID, notification(station, bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(station: RadioStation, bytes: Long): Notification {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "Enregistrement radio", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
val stop = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
Intent(this, RecordingService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_offline)
|
||||||
|
.setContentTitle("Enregistrement de ${station.name}")
|
||||||
|
.setContentText(if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…")
|
||||||
|
.setContentIntent(open)
|
||||||
|
.addAction(0, "Arrêter", stop)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.toStation(): RadioStation? {
|
||||||
|
val url = getStringExtra(EXTRA_URL) ?: return null
|
||||||
|
return RadioStation(
|
||||||
|
id = getStringExtra(EXTRA_ID).orEmpty(),
|
||||||
|
name = getStringExtra(EXTRA_NAME) ?: "Radio",
|
||||||
|
streamUrl = url,
|
||||||
|
codec = getStringExtra(EXTRA_CODEC).orEmpty(),
|
||||||
|
bitrate = getIntExtra(EXTRA_BITRATE, 0),
|
||||||
|
country = "",
|
||||||
|
tags = "",
|
||||||
|
faviconUrl = getStringExtra(EXTRA_FAVICON),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RadioStation.extension(): String =
|
||||||
|
if (codec.equals("AAC", ignoreCase = true) || codec.startsWith("AAC", true)) "aac" else "mp3"
|
||||||
|
|
||||||
|
private fun String.sanitized(): String =
|
||||||
|
replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(' ', '_').take(40).ifBlank { "radio" }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACTION_START = "fr.tuxgyver.cachette.RECORD_START"
|
||||||
|
private const val ACTION_STOP = "fr.tuxgyver.cachette.RECORD_STOP"
|
||||||
|
private const val EXTRA_ID = "id"
|
||||||
|
private const val EXTRA_NAME = "name"
|
||||||
|
private const val EXTRA_URL = "url"
|
||||||
|
private const val EXTRA_CODEC = "codec"
|
||||||
|
private const val EXTRA_BITRATE = "bitrate"
|
||||||
|
private const val EXTRA_FAVICON = "favicon"
|
||||||
|
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
|
||||||
|
|
||||||
|
fun start(context: Context, station: RadioStation) {
|
||||||
|
val intent = Intent(context, RecordingService::class.java)
|
||||||
|
.setAction(ACTION_START)
|
||||||
|
.putExtra(EXTRA_ID, station.id)
|
||||||
|
.putExtra(EXTRA_NAME, station.name)
|
||||||
|
.putExtra(EXTRA_URL, station.streamUrl)
|
||||||
|
.putExtra(EXTRA_CODEC, station.codec)
|
||||||
|
.putExtra(EXTRA_BITRATE, station.bitrate)
|
||||||
|
.putExtra(EXTRA_FAVICON, station.faviconUrl)
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(
|
||||||
|
Intent(context, RecordingService::class.java).setAction(ACTION_STOP)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen
|
|||||||
import fr.tuxgyver.cachette.ui.library.LibraryScreen
|
import fr.tuxgyver.cachette.ui.library.LibraryScreen
|
||||||
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
|
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
|
||||||
import fr.tuxgyver.cachette.ui.player.MiniPlayer
|
import fr.tuxgyver.cachette.ui.player.MiniPlayer
|
||||||
|
import fr.tuxgyver.cachette.ui.radio.RadioScreen
|
||||||
import fr.tuxgyver.cachette.ui.playlists.AddToPlaylistSheet
|
import fr.tuxgyver.cachette.ui.playlists.AddToPlaylistSheet
|
||||||
import fr.tuxgyver.cachette.ui.playlists.PlaylistDetailScreen
|
import fr.tuxgyver.cachette.ui.playlists.PlaylistDetailScreen
|
||||||
import fr.tuxgyver.cachette.ui.playlists.PlaylistsScreen
|
import fr.tuxgyver.cachette.ui.playlists.PlaylistsScreen
|
||||||
@@ -63,6 +64,7 @@ private const val BROWSE_ROUTE = "browse/{source}"
|
|||||||
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
||||||
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
|
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
|
||||||
private const val PLAYLIST_ROUTE = "playlist/{playlist}"
|
private const val PLAYLIST_ROUTE = "playlist/{playlist}"
|
||||||
|
private const val RADIO_ROUTE = "radio"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CachetteRoot(container: AppContainer) {
|
fun CachetteRoot(container: AppContainer) {
|
||||||
@@ -143,6 +145,16 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(RADIO_ROUTE) {
|
||||||
|
RadioScreen(
|
||||||
|
container = container,
|
||||||
|
currentMediaId = playerState.mediaId,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
|
onMessage = notify,
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composable(PLAYLIST_ROUTE) { entry ->
|
composable(PLAYLIST_ROUTE) { entry ->
|
||||||
PlaylistDetailScreen(
|
PlaylistDetailScreen(
|
||||||
container = container,
|
container = container,
|
||||||
@@ -161,6 +173,7 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
onMessage = notify,
|
onMessage = notify,
|
||||||
onBrowseSource = { navController.navigate("browse/${it.name}") },
|
onBrowseSource = { navController.navigate("browse/${it.name}") },
|
||||||
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
|
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
|
||||||
|
onBrowseRadio = { navController.navigate(RADIO_ROUTE) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.radio
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
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.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.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
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.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import fr.tuxgyver.cachette.radio.RADIO_GENRES
|
||||||
|
import fr.tuxgyver.cachette.radio.RadioStation
|
||||||
|
import fr.tuxgyver.cachette.radio.Recorder
|
||||||
|
import fr.tuxgyver.cachette.radio.RecordingService
|
||||||
|
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.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les stations les plus écoutées, à écouter en direct ou à enregistrer.
|
||||||
|
*
|
||||||
|
* L'enregistrement produit un fichier local qui rejoint la bibliothèque : il n'a donc rien à
|
||||||
|
* télécharger ensuite, il est déjà hors connexion.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RadioScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
currentMediaId: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val status by Recorder.status.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var genre by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
val outcome = if (query.isBlank()) container.radio.popular(genre)
|
||||||
|
else container.radio.search(query)
|
||||||
|
outcome.fold(
|
||||||
|
onSuccess = { stations = it; if (it.isEmpty()) error = "Aucune station" },
|
||||||
|
onFailure = { error = it.message ?: "Annuaire indisponible" },
|
||||||
|
)
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(genre) { load() }
|
||||||
|
|
||||||
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Radio") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (status.isRecording) {
|
||||||
|
// La progression n'est publiée que tous les 256 Ko : le chronomètre se rafraîchit
|
||||||
|
// donc de lui-même, sinon il semblerait figé entre deux paliers.
|
||||||
|
var tick by remember { mutableStateOf(0L) }
|
||||||
|
LaunchedEffect(status.startedAt) {
|
||||||
|
while (true) {
|
||||||
|
tick = System.currentTimeMillis()
|
||||||
|
delay(1_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RecordingBanner(
|
||||||
|
station = status.station!!,
|
||||||
|
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
|
||||||
|
bytes = status.bytes,
|
||||||
|
onStop = {
|
||||||
|
RecordingService.stop(context)
|
||||||
|
onMessage("Enregistrement versé dans la bibliothèque")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
placeholder = { Text("Rechercher une station") },
|
||||||
|
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { load() }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = genre == null,
|
||||||
|
onClick = { genre = null; query = "" },
|
||||||
|
label = { Text("Les plus écoutées") },
|
||||||
|
)
|
||||||
|
RADIO_GENRES.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = genre == option,
|
||||||
|
onClick = { genre = option; query = "" },
|
||||||
|
label = { Text(option.replaceFirstChar(Char::uppercase)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
error != null -> Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error!!,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> LazyColumn(
|
||||||
|
contentPadding = listPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(stations, key = { it.id }) { station ->
|
||||||
|
StationRow(
|
||||||
|
station = station,
|
||||||
|
playing = station.asEntry().id == currentMediaId,
|
||||||
|
recording = status.station?.id == station.id,
|
||||||
|
onListen = {
|
||||||
|
val entry = station.asEntry()
|
||||||
|
container.library.preview(listOf(entry))
|
||||||
|
container.player.play(listOf(entry), 0)
|
||||||
|
},
|
||||||
|
onRecord = {
|
||||||
|
if (status.isRecording) {
|
||||||
|
onMessage("Un enregistrement est déjà en cours")
|
||||||
|
} else {
|
||||||
|
RecordingService.start(context, station)
|
||||||
|
onMessage("Enregistrement de ${station.name} démarré")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(24.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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",
|
||||||
|
source = SourceKind.URL,
|
||||||
|
kind = MediaKind.AUDIO,
|
||||||
|
title = name,
|
||||||
|
subtitle = subtitle.ifBlank { "Radio en direct" },
|
||||||
|
uri = streamUrl,
|
||||||
|
artworkUri = faviconUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordingBanner(
|
||||||
|
station: RadioStation,
|
||||||
|
elapsedMs: Long,
|
||||||
|
bytes: Long,
|
||||||
|
onStop: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.FiberManualRecord,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFD32F2F),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = station.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${formatDuration(elapsedMs)} · ${formatBytes(bytes)}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onStop) {
|
||||||
|
Icon(Icons.Rounded.Stop, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Arrêter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StationRow(
|
||||||
|
station: RadioStation,
|
||||||
|
playing: Boolean,
|
||||||
|
recording: Boolean,
|
||||||
|
onListen: () -> Unit,
|
||||||
|
onRecord: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (playing) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surface,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onListen),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Artwork(
|
||||||
|
uri = station.faviconUrl,
|
||||||
|
title = station.name,
|
||||||
|
kind = MediaKind.AUDIO,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = station.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = station.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
|
||||||
|
contentDescription = if (playing) "En écoute" else "Écouter en direct",
|
||||||
|
tint = if (playing) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
IconButton(onClick = onRecord) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.FiberManualRecord,
|
||||||
|
contentDescription = "Enregistrer cette station",
|
||||||
|
tint = if (recording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ 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.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.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Info
|
import androidx.compose.material.icons.rounded.Info
|
||||||
import androidx.compose.material.icons.rounded.Link
|
import androidx.compose.material.icons.rounded.Link
|
||||||
@@ -59,6 +60,7 @@ fun SourcesScreen(
|
|||||||
onMessage: (String) -> Unit,
|
onMessage: (String) -> Unit,
|
||||||
onBrowseSource: (SourceKind) -> Unit,
|
onBrowseSource: (SourceKind) -> Unit,
|
||||||
onBrowseCatalog: (CatalogId) -> Unit,
|
onBrowseCatalog: (CatalogId) -> Unit,
|
||||||
|
onBrowseRadio: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
var sheet by remember { mutableStateOf<ImportKind?>(null) }
|
var sheet by remember { mutableStateOf<ImportKind?>(null) }
|
||||||
@@ -122,6 +124,19 @@ fun SourcesScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
val recordings = entries.count { it.source == SourceKind.RECORDING }
|
||||||
|
SourceCard(
|
||||||
|
icon = Icons.Rounded.Radio,
|
||||||
|
title = "Radio",
|
||||||
|
status = if (recordings > 0) "$recordings enregistrement(s)" else "Direct et enregistrement",
|
||||||
|
description = "Les stations les plus écoutées. À suivre en direct, ou à enregistrer pour l'écouter plus tard.",
|
||||||
|
actionLabel = "Ouvrir la radio",
|
||||||
|
onAction = onBrowseRadio,
|
||||||
|
onOpen = onBrowseRadio,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
|
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|||||||
Reference in new issue
Block a user