diff --git a/CHANGELOG.md b/CHANGELOG.md index 833c165..ec3e3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/) 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 ### Ajouté diff --git a/README.md b/README.md index 7265ab1..7e8f536 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-1.2.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-1.3.0-6B4EFF)](VERSION) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les @@ -41,7 +41,8 @@ sans la moindre connexion. | 🎛️ **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 | | 🌙 **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 | --- @@ -56,6 +57,7 @@ sans la moindre connexion. | 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons | | 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue | | 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres | +| 📻 Radio | ✅ | Annuaire Radio Browser, direct et enregistrement (flux HLS exclus) | ### 🔎 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 ├── offline/ Cache Media3, gestionnaire et service de téléchargement ├── 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 └── ui/ Compose — thème, bibliothèque, hors ligne, playlists, sources, parcours des catalogues, choix des pistes, lecteur diff --git a/VERSION b/VERSION index 26aaba0..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eb2751f..346c29c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -59,6 +59,11 @@ + + >(emptyMap()) + /** Identifiants dont une relance est programmée. */ + private val retrying = MutableStateFlow(emptySet()) + private val ticker = flow { while (true) { emit(Unit) @@ -60,20 +65,58 @@ class DownloadTracker( } val states: StateFlow> = - indexed.combine(ticker) { snapshot, _ -> - snapshot + downloadManager.currentDownloads.associate { it.request.id to it.toOfflineState() } + combine(indexed, ticker, retrying) { snapshot, _, pending -> + 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()) + /** + * 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() + private val listener = object : DownloadManager.Listener { 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() } } override fun onDownloadRemoved(manager: DownloadManager, download: Download) { + attempts.remove(download.request.id) 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 { downloadManager.addListener(listener) scope.launch { reload() } @@ -81,6 +124,8 @@ class DownloadTracker( fun download(entry: MediaEntry) { 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()) .setData(entry.title.toByteArray()) .build() @@ -108,6 +153,10 @@ class DownloadTracker( indexed.value = snapshot } + private companion object { + val RETRY_DELAYS_MS = longArrayOf(5_000, 15_000, 45_000) + } + private fun Download.toOfflineState() = OfflineState( id = request.id, state = state, diff --git a/app/src/main/java/fr/tuxgyver/cachette/radio/RadioDirectory.kt b/app/src/main/java/fr/tuxgyver/cachette/radio/RadioDirectory.kt new file mode 100644 index 0000000..8d143d1 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/RadioDirectory.kt @@ -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> = + 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> = + query("?limit=60&hidebroken=true&order=clickcount&reverse=true" + + "&name=${URLEncoder.encode(name.trim(), "UTF-8")}") + + private suspend fun query(suffix: String): Result> = 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") diff --git a/app/src/main/java/fr/tuxgyver/cachette/radio/RadioStation.kt b/app/src/main/java/fr/tuxgyver/cachette/radio/RadioStation.kt new file mode 100644 index 0000000..1132dd4 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/RadioStation.kt @@ -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(" · ") +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt b/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt new file mode 100644 index 0000000..778a0b4 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/Recorder.kt @@ -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 = _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() + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt b/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt new file mode 100644 index 0000000..14c9224 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/radio/RecordingService.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt index 341c6ae..58ac547 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt @@ -45,6 +45,7 @@ import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen import fr.tuxgyver.cachette.ui.library.LibraryScreen import fr.tuxgyver.cachette.ui.offline.DownloadsScreen 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.PlaylistDetailScreen 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_ITEM_ROUTE = "catalog/{catalog}/item" private const val PLAYLIST_ROUTE = "playlist/{playlist}" +private const val RADIO_ROUTE = "radio" @Composable 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 -> PlaylistDetailScreen( container = container, @@ -161,6 +173,7 @@ fun CachetteRoot(container: AppContainer) { onMessage = notify, onBrowseSource = { navController.navigate("browse/${it.name}") }, onBrowseCatalog = { navController.navigate("catalog/${it.name}") }, + onBrowseRadio = { navController.navigate(RADIO_ROUTE) }, ) } diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/radio/RadioScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/radio/RadioScreen.kt new file mode 100644 index 0000000..b90f914 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/radio/RadioScreen.kt @@ -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(null) } + var stations by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(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, + ) + } + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt index 285dcfb..3f5f095 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt @@ -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.rounded.LibraryMusic 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.Info import androidx.compose.material.icons.rounded.Link @@ -59,6 +60,7 @@ fun SourcesScreen( onMessage: (String) -> Unit, onBrowseSource: (SourceKind) -> Unit, onBrowseCatalog: (CatalogId) -> Unit, + onBrowseRadio: () -> Unit, ) { val entries by container.library.entries.collectAsStateWithLifecycle() var sheet by remember { mutableStateOf(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 { Spacer(Modifier.size(8.dp)) Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)