Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4017c71ca7 |
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/)
|
||||
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é
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/jetpack/compose)
|
||||
[](https://developer.android.com/media/media3)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
[](#-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
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".radio.RecordingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.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.OfflineModule
|
||||
import fr.tuxgyver.cachette.playback.PlayerController
|
||||
import fr.tuxgyver.cachette.radio.RadioDirectory
|
||||
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
|
||||
import fr.tuxgyver.cachette.sources.Catalog
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
@@ -40,6 +41,8 @@ class AppContainer(context: Context) {
|
||||
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||
val playlists = PlaylistStore(context, scope)
|
||||
|
||||
val radio = RadioDirectory()
|
||||
|
||||
val urlImporter = UrlImporter()
|
||||
val rssImporter = RssImporter()
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ enum class SourceKind(val label: String) {
|
||||
LOCAL("Appareil"),
|
||||
URL("Lien direct"),
|
||||
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. */
|
||||
val sizeBytes: Long? = null,
|
||||
) {
|
||||
/** Un média local est déjà disponible hors connexion : il n'a rien à télécharger. */
|
||||
val isRemote: Boolean get() = source != SourceKind.LOCAL
|
||||
/** 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 && source != SourceKind.RECORDING
|
||||
}
|
||||
@@ -28,13 +28,15 @@ data class OfflineState(
|
||||
val state: Int,
|
||||
val percent: Float,
|
||||
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 isFailed: Boolean get() = state == Download.STATE_FAILED
|
||||
val isFailed: Boolean get() = state == Download.STATE_FAILED && !retrying
|
||||
val isPending: Boolean
|
||||
get() = state == Download.STATE_QUEUED || state == Download.STATE_RESTARTING
|
||||
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(
|
||||
private val context: Context,
|
||||
private val downloadManager: DownloadManager,
|
||||
scope: CoroutineScope,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
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 {
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
@@ -60,20 +65,58 @@ class DownloadTracker(
|
||||
}
|
||||
|
||||
val states: StateFlow<Map<String, OfflineState>> =
|
||||
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<String, Int>()
|
||||
|
||||
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,
|
||||
|
||||
@@ -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.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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.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<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 {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
Reference in new issue
Block a user