Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4017c71ca7 | ||
|
|
2dabfdbc84 |
No files matched your search
@@ -5,6 +5,59 @@ 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
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- Écran de choix des pistes. Un album ou un livre n'est plus une boîte noire : ses pistes sont
|
||||||
|
listées avec leur durée et leur taille, le total est annoncé, et le bouton dit exactement ce
|
||||||
|
qu'on s'apprête à télécharger (« Garder 4,2 Mo »). Les variantes d'un même morceau sont
|
||||||
|
regroupées — autant de lignes que d'œuvres, pas que de formats.
|
||||||
|
- Écoute en ligne avant téléchargement : un appui sur une piste la joue en streaming sans rien
|
||||||
|
mettre en cache.
|
||||||
|
- Playlists : listes nommées, ajout par appui long sur un média ou depuis l'écran de lecture,
|
||||||
|
lecture enchaînée, suppression piste par piste.
|
||||||
|
- Contrôles de lecture complétés : arrêt, saut de 15 secondes en avant et en arrière.
|
||||||
|
- Filtrage par langue des catalogues (français, anglais, allemand, espagnol, italien, néerlandais).
|
||||||
|
- Nouvelle source libre : **Wikimedia Commons**, pour les enregistrements classiques et les
|
||||||
|
archives sonores du domaine public.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- Le filtre de langue de LibriVox ne pouvait pas fonctionner côté serveur : l'API accepte le
|
||||||
|
paramètre `language` puis l'ignore (`language=French` seul renvoie des livres anglais). Le tri
|
||||||
|
se fait désormais sur le champ `language` de chaque livre.
|
||||||
|
- Délais réseau ajustés d'après la latence mesurée des services : LibriVox répond en 14 à
|
||||||
|
16 secondes quelle que soit la taille demandée, ce qui faisait expirer les requêtes parallèles.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Deux catalogues ont été évalués puis écartés sur mesure plutôt que sur principe : **Openverse**
|
||||||
|
refuse désormais l'accès anonyme (401 sur huit requêtes sur huit) et **ccMixter** exige un
|
||||||
|
en-tête `Referer` que Media3 ne permet pas de poser par téléchargement.
|
||||||
|
|
||||||
## [1.1.0] — 2026-09-11
|
## [1.1.0] — 2026-09-11
|
||||||
|
|
||||||
### 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
|
||||||
@@ -30,13 +30,19 @@ sans la moindre connexion.
|
|||||||
| 🎵 **Audio et vidéo** | Un seul lecteur, une seule file d'attente, la vidéo bascule sur une surface dédiée |
|
| 🎵 **Audio et vidéo** | Un seul lecteur, une seule file d'attente, la vidéo bascule sur une surface dédiée |
|
||||||
| 📥 **Mise en cache explicite** | Rien n'est téléchargé sans un geste : vous décidez quoi garder |
|
| 📥 **Mise en cache explicite** | Rien n'est téléchargé sans un geste : vous décidez quoi garder |
|
||||||
| 🔍 **Parcours des sources** | On entre dans une source, on coche ce qu'on veut, on télécharge en lot |
|
| 🔍 **Parcours des sources** | On entre dans une source, on coche ce qu'on veut, on télécharge en lot |
|
||||||
|
| 🎧 **Écoute avant téléchargement** | Chaque piste s'essaie en ligne d'un appui, sans rien mettre en cache |
|
||||||
|
| 🧾 **Choix piste par piste** | Un album ou un livre affiche ses pistes avec leur taille : on n'emporte que ce qu'on veut |
|
||||||
|
| 🌍 **Filtrage par langue** | Français, anglais, allemand, espagnol, italien, néerlandais |
|
||||||
|
| 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre, lecture enchaînée |
|
||||||
| 🎼 **Catalogues libres de droits** | Recherche intégrée dans Internet Archive et LibriVox, sans compte ni clé d'API |
|
| 🎼 **Catalogues libres de droits** | Recherche intégrée dans Internet Archive et LibriVox, sans compte ni clé d'API |
|
||||||
| 📻 **Podcasts RSS** | Un flux, ses épisodes, et le choix de ceux qu'on emporte |
|
| 📻 **Podcasts RSS** | Un flux, ses épisodes, et le choix de ceux qu'on emporte |
|
||||||
| 🔗 **Liens directs** | N'importe quelle URL audio ou vidéo, y compris un serveur média auto-hébergé |
|
| 🔗 **Liens directs** | N'importe quelle URL audio ou vidéo, y compris un serveur média auto-hébergé |
|
||||||
| 📱 **Contenu de l'appareil** | Lecture de la bibliothèque MediaStore, déjà disponible hors connexion par nature |
|
| 📱 **Contenu de l'appareil** | Lecture de la bibliothèque MediaStore, déjà disponible hors connexion par nature |
|
||||||
| 🎛️ **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 |
|
||||||
| 🌙 **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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -49,7 +55,17 @@ sans la moindre connexion.
|
|||||||
| 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo |
|
| 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo |
|
||||||
| 📻 Podcast RSS | ✅ | Flux standard avec `<enclosure>` |
|
| 📻 Podcast RSS | ✅ | Flux standard avec `<enclosure>` |
|
||||||
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
|
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
|
||||||
| 📚 LibriVox | ✅ | Livres audio du domaine public |
|
| 📚 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
|
||||||
|
|
||||||
|
| Candidat | Pourquoi non |
|
||||||
|
|---|---|
|
||||||
|
| **Openverse** | A cessé de servir l'accès anonyme : **401 sur huit requêtes sur huit**, et jusqu'à 21 s de latence quand il répondait encore. Exige désormais une inscription. |
|
||||||
|
| **ccMixter** | La recherche fonctionne, mais les fichiers ne sont servis qu'avec un en-tête `Referer` — que Media3 ne permet pas de poser par téléchargement. |
|
||||||
|
| **Radio Browser** | Flux radio continus : rien à mettre en cache. |
|
||||||
|
|
||||||
### ⚖️ Ce que Cachette ne fait pas — et pourquoi
|
### ⚖️ Ce que Cachette ne fait pas — et pourquoi
|
||||||
|
|
||||||
@@ -216,8 +232,10 @@ 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, sources, parcours, lecteur
|
└── ui/ Compose — thème, bibliothèque, hors ligne, playlists, sources,
|
||||||
|
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"
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import android.content.Context
|
|||||||
import fr.tuxgyver.cachette.library.ImportedStore
|
import fr.tuxgyver.cachette.library.ImportedStore
|
||||||
import fr.tuxgyver.cachette.library.LibraryRepository
|
import fr.tuxgyver.cachette.library.LibraryRepository
|
||||||
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
||||||
|
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
|
||||||
|
import fr.tuxgyver.cachette.sources.CatalogItem
|
||||||
|
import fr.tuxgyver.cachette.sources.WikimediaCatalog
|
||||||
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
|
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
|
||||||
import fr.tuxgyver.cachette.sources.RssImporter
|
import fr.tuxgyver.cachette.sources.RssImporter
|
||||||
import fr.tuxgyver.cachette.sources.UrlImporter
|
import fr.tuxgyver.cachette.sources.UrlImporter
|
||||||
@@ -35,6 +39,9 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val imported = ImportedStore(context, scope)
|
val imported = ImportedStore(context, scope)
|
||||||
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||||
|
val playlists = PlaylistStore(context, scope)
|
||||||
|
|
||||||
|
val radio = RadioDirectory()
|
||||||
|
|
||||||
val urlImporter = UrlImporter()
|
val urlImporter = UrlImporter()
|
||||||
val rssImporter = RssImporter()
|
val rssImporter = RssImporter()
|
||||||
@@ -42,8 +49,15 @@ class AppContainer(context: Context) {
|
|||||||
val catalogs: Map<CatalogId, Catalog> = listOf(
|
val catalogs: Map<CatalogId, Catalog> = listOf(
|
||||||
ArchiveOrgCatalog(),
|
ArchiveOrgCatalog(),
|
||||||
LibriVoxCatalog(rssImporter),
|
LibriVoxCatalog(rssImporter),
|
||||||
|
WikimediaCatalog(),
|
||||||
).associateBy { it.id }
|
).associateBy { it.id }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat de catalogue en cours de consultation.
|
||||||
|
* Un identifiant de route ne suffirait pas à le reconstituer sans un appel réseau de plus.
|
||||||
|
*/
|
||||||
|
var openedCatalogItem: CatalogItem? = null
|
||||||
|
|
||||||
val player = PlayerController(context, scope) { id -> library.entry(id) }
|
val player = PlayerController(context, scope) { id -> library.entry(id) }
|
||||||
|
|
||||||
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
|
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
|
||||||
|
|||||||
@@ -39,7 +39,18 @@ class LibraryRepository(
|
|||||||
|
|
||||||
fun hasLocalPermission(): Boolean = scanner.hasPermission()
|
fun hasLocalPermission(): Boolean = scanner.hasPermission()
|
||||||
|
|
||||||
fun entry(id: String): MediaEntry? = entries.value.firstOrNull { it.id == id }
|
/**
|
||||||
|
* Titres écoutés en aperçu, pas encore ajoutés à la bibliothèque.
|
||||||
|
* Le service de lecture résout les URI par identifiant : sans eux, un aperçu ne démarrerait pas.
|
||||||
|
*/
|
||||||
|
private var previewing: List<MediaEntry> = emptyList()
|
||||||
|
|
||||||
|
fun preview(entries: List<MediaEntry>) {
|
||||||
|
previewing = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
fun entry(id: String): MediaEntry? =
|
||||||
|
entries.value.firstOrNull { it.id == id } ?: previewing.firstOrNull { it.id == id }
|
||||||
|
|
||||||
suspend fun forget(id: String) = imported.remove(id)
|
suspend fun forget(id: String) = imported.remove(id)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package fr.tuxgyver.cachette.library
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import fr.tuxgyver.cachette.model.Playlist
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Persistance des playlists, sur le même principe que les médias importés. */
|
||||||
|
class PlaylistStore(context: Context, scope: CoroutineScope) {
|
||||||
|
|
||||||
|
private val file = File(context.filesDir, "playlists.json")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val _playlists = MutableStateFlow<List<Playlist>>(emptyList())
|
||||||
|
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { _playlists.value = read() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun create(name: String): Playlist = mutate { current ->
|
||||||
|
val playlist = Playlist(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = name.trim().ifBlank { "Sans titre" },
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
current + playlist
|
||||||
|
}.last()
|
||||||
|
|
||||||
|
suspend fun delete(id: String) {
|
||||||
|
mutate { current -> current.filterNot { it.id == id } }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun rename(id: String, name: String) {
|
||||||
|
mutate { current ->
|
||||||
|
current.map { if (it.id == id) it.copy(name = name.trim().ifBlank { it.name }) else it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ajoute sans doublon : une piste déjà présente n'est pas ajoutée une seconde fois. */
|
||||||
|
suspend fun add(playlistId: String, entryIds: List<String>) {
|
||||||
|
mutate { current ->
|
||||||
|
current.map { playlist ->
|
||||||
|
if (playlist.id != playlistId) playlist
|
||||||
|
else playlist.copy(entryIds = (playlist.entryIds + entryIds).distinct())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun remove(playlistId: String, entryId: String) {
|
||||||
|
mutate { current ->
|
||||||
|
current.map { playlist ->
|
||||||
|
if (playlist.id != playlistId) playlist
|
||||||
|
else playlist.copy(entryIds = playlist.entryIds - entryId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playlist(id: String): Playlist? = _playlists.value.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
private suspend fun mutate(transform: (List<Playlist>) -> List<Playlist>): List<Playlist> =
|
||||||
|
mutex.withLock {
|
||||||
|
val updated = transform(_playlists.value)
|
||||||
|
_playlists.value = updated
|
||||||
|
write(updated)
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun read(): List<Playlist> = withContext(Dispatchers.IO) {
|
||||||
|
if (!file.exists()) return@withContext emptyList()
|
||||||
|
runCatching { json.decodeFromString<List<Playlist>>(file.readText()) }.getOrElse { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(playlists: List<Playlist>) = withContext(Dispatchers.IO) {
|
||||||
|
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||||
|
tmp.writeText(json.encodeToString(playlists))
|
||||||
|
tmp.renameTo(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,7 +29,9 @@ data class MediaEntry(
|
|||||||
val artworkUri: String? = null,
|
val artworkUri: String? = null,
|
||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
val addedAt: Long = 0L,
|
val addedAt: Long = 0L,
|
||||||
|
/** 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. */
|
/** 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
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package fr.tuxgyver.cachette.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une playlist ne contient que des identifiants.
|
||||||
|
*
|
||||||
|
* Les métadonnées appartiennent à la bibliothèque : les dupliquer ici les laisserait diverger
|
||||||
|
* dès qu'un média est renommé ou retiré.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Playlist(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val entryIds: List<String> = emptyList(),
|
||||||
|
val createdAt: Long = 0L,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,19 @@ class ArchiveOrgCatalog : Catalog {
|
|||||||
|
|
||||||
override val id = CatalogId.ARCHIVE
|
override val id = CatalogId.ARCHIVE
|
||||||
|
|
||||||
|
override val languages = COMMON_LANGUAGES
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
|
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
|
||||||
// dès la recherche plutôt que de les proposer puis d'échouer.
|
// dès la recherche plutôt que de les proposer puis d'échouer.
|
||||||
|
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
|
||||||
val q = URLEncoder.encode(
|
val q = URLEncoder.encode(
|
||||||
"($query) AND mediatype:(audio OR movies) AND NOT access-restricted-item:true",
|
"($query) AND mediatype:(audio OR movies)$languageClause" +
|
||||||
|
" AND NOT access-restricted-item:true",
|
||||||
"UTF-8",
|
"UTF-8",
|
||||||
)
|
)
|
||||||
val url = "https://archive.org/advancedsearch.php?q=$q" +
|
val url = "https://archive.org/advancedsearch.php?q=$q" +
|
||||||
@@ -57,7 +62,7 @@ class ArchiveOrgCatalog : Catalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
|
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
|
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
|
||||||
|
|
||||||
@@ -67,36 +72,43 @@ class ArchiveOrgCatalog : Catalog {
|
|||||||
|
|
||||||
val wantsVideo = item.hint == "Vidéo"
|
val wantsVideo = item.hint == "Vidéo"
|
||||||
val extensions = if (wantsVideo) VIDEO_EXTENSIONS else AUDIO_EXTENSIONS
|
val extensions = if (wantsVideo) VIDEO_EXTENSIONS else AUDIO_EXTENSIONS
|
||||||
val files = (metadata["files"] as? JsonArray).orEmpty()
|
val files = (metadata["files"] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject }
|
||||||
|
|
||||||
// Un élément expose souvent l'original et ses dérivés. Le plus petit est le dérivé
|
val playable = files.filter { file ->
|
||||||
// compressé : c'est celui qu'on veut sur un téléphone.
|
val name = file.text("name").orEmpty().lowercase()
|
||||||
val playable = files.mapNotNull { it as? JsonObject }
|
extensions.any { name.endsWith(it) }
|
||||||
.filter { file ->
|
}
|
||||||
val name = file.text("name").orEmpty().lowercase()
|
if (playable.isEmpty()) {
|
||||||
extensions.any { name.endsWith(it) }
|
throw IllegalStateException(
|
||||||
}
|
|
||||||
.minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE }
|
|
||||||
?: throw IllegalStateException(
|
|
||||||
"Aucun fichier ${if (wantsVideo) "vidéo" else "audio"} lisible dans « ${item.title} »"
|
"Aucun fichier ${if (wantsVideo) "vidéo" else "audio"} lisible dans « ${item.title} »"
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val name = playable.text("name")!!
|
// Un élément expose l'original et ses dérivés du même morceau. On regroupe par nom de
|
||||||
val seconds = playable.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
// base et on garde le plus léger de chaque groupe : autant de pistes que d'œuvres,
|
||||||
|
// pas autant que de formats.
|
||||||
listOf(
|
playable.groupBy { it.text("name")!!.substringBeforeLast('.').lowercase() }
|
||||||
MediaEntry(
|
.values
|
||||||
id = stableId("ia:", "${item.id}/$name"),
|
.mapNotNull { variants ->
|
||||||
source = SourceKind.URL,
|
variants.minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE }
|
||||||
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
|
}
|
||||||
title = item.title,
|
.sortedBy { it.text("name") }
|
||||||
subtitle = item.subtitle,
|
.map { file ->
|
||||||
uri = "https://archive.org/download/${item.id}/${encodePath(name)}",
|
val name = file.text("name")!!
|
||||||
artworkUri = item.artworkUri,
|
val seconds = file.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
||||||
durationMs = seconds * 1000,
|
MediaEntry(
|
||||||
addedAt = System.currentTimeMillis(),
|
id = stableId("ia:", "${item.id}/$name"),
|
||||||
)
|
source = SourceKind.URL,
|
||||||
)
|
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||||
|
title = file.text("title") ?: name.substringBeforeLast('.'),
|
||||||
|
subtitle = item.title,
|
||||||
|
uri = "https://archive.org/download/${item.id}/${encodePath(name)}",
|
||||||
|
artworkUri = item.artworkUri,
|
||||||
|
durationMs = seconds * 1000,
|
||||||
|
addedAt = System.currentTimeMillis(),
|
||||||
|
sizeBytes = file.text("size")?.toLongOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,28 +2,36 @@ package fr.tuxgyver.cachette.sources
|
|||||||
|
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
|
||||||
/** Un résultat de recherche dans un catalogue, avant qu'on sache quel fichier exact il désigne. */
|
/** Un résultat de recherche : un album, un livre ou un morceau, pas encore un fichier. */
|
||||||
data class CatalogItem(
|
data class CatalogItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val artworkUri: String? = null,
|
val artworkUri: String? = null,
|
||||||
val hint: String = "",
|
val hint: String = "",
|
||||||
/** Taille approximative de l'élément, quand le catalogue la connaît avant résolution. */
|
/** Taille de l'ensemble, quand le catalogue l'annonce. */
|
||||||
val sizeBytes: Long? = null,
|
val sizeBytes: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Langue proposée au filtrage. [code] est la valeur attendue par l'API du catalogue. */
|
||||||
|
data class CatalogLanguage(val code: String, val label: String)
|
||||||
|
|
||||||
/** Catalogue public interrogeable sans compte ni clé d'API. */
|
/** Catalogue public interrogeable sans compte ni clé d'API. */
|
||||||
interface Catalog {
|
interface Catalog {
|
||||||
val id: CatalogId
|
val id: CatalogId
|
||||||
|
|
||||||
suspend fun search(query: String): Result<List<CatalogItem>>
|
/** Vide si le catalogue ne distingue pas les langues (la musique instrumentale, typiquement). */
|
||||||
|
val languages: List<CatalogLanguage> get() = emptyList()
|
||||||
|
|
||||||
|
suspend fun search(query: String, language: String? = null): Result<List<CatalogItem>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforme un résultat en entrées jouables. Un livre audio en rend plusieurs, un morceau une
|
* Les pistes d'un résultat, avec leur taille.
|
||||||
* seule : la résolution est faite ici, à la sélection, et pas pendant la recherche.
|
*
|
||||||
|
* Un album ou un livre en compte plusieurs : c'est à l'utilisateur de choisir lesquelles il
|
||||||
|
* emporte, pas à l'application de décider pour lui.
|
||||||
*/
|
*/
|
||||||
suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>>
|
suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>>
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class CatalogId(
|
enum class CatalogId(
|
||||||
@@ -44,4 +52,20 @@ enum class CatalogId(
|
|||||||
license = "Domaine public",
|
license = "Domaine public",
|
||||||
suggestion = "Jules Verne",
|
suggestion = "Jules Verne",
|
||||||
),
|
),
|
||||||
|
WIKIMEDIA(
|
||||||
|
title = "Wikimedia Commons",
|
||||||
|
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
|
||||||
|
license = "Domaine public / Creative Commons",
|
||||||
|
suggestion = "Chopin",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Langues les plus fournies, communes à LibriVox et archive.org. */
|
||||||
|
val COMMON_LANGUAGES = listOf(
|
||||||
|
CatalogLanguage("French", "Français"),
|
||||||
|
CatalogLanguage("English", "Anglais"),
|
||||||
|
CatalogLanguage("German", "Allemand"),
|
||||||
|
CatalogLanguage("Spanish", "Espagnol"),
|
||||||
|
CatalogLanguage("Italian", "Italien"),
|
||||||
|
CatalogLanguage("Dutch", "Néerlandais"),
|
||||||
|
)
|
||||||
@@ -9,11 +9,15 @@ internal fun stableId(prefix: String, value: String): String {
|
|||||||
return prefix + digest.joinToString("") { "%02x".format(it) }.take(16)
|
return prefix + digest.joinToString("") { "%02x".format(it) }.take(16)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun openConnection(url: String, method: String = "GET"): HttpURLConnection =
|
internal fun openConnection(
|
||||||
|
url: String,
|
||||||
|
method: String = "GET",
|
||||||
|
readTimeoutMs: Int = 20_000,
|
||||||
|
): HttpURLConnection =
|
||||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = method
|
requestMethod = method
|
||||||
instanceFollowRedirects = true
|
instanceFollowRedirects = true
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = 20_000
|
readTimeout = readTimeoutMs
|
||||||
setRequestProperty("User-Agent", "Cachette/1.0 (Android)")
|
setRequestProperty("User-Agent", "Cachette/1.1 (Android)")
|
||||||
}
|
}
|
||||||
@@ -30,10 +30,17 @@ class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
|||||||
|
|
||||||
override val id = CatalogId.LIBRIVOX
|
override val id = CatalogId.LIBRIVOX
|
||||||
|
|
||||||
|
override val languages = COMMON_LANGUAGES
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
// Le paramètre `language` de l'API est accepté puis ignoré : vérifié, `language=French`
|
||||||
|
// seul renvoie des livres anglais. Le tri se fait donc sur le champ `language` de
|
||||||
|
// chaque livre, et on demande davantage de résultats pour qu'il en reste après filtrage.
|
||||||
|
val limit = if (language == null) 25 else 100
|
||||||
val cleaned = query.trim()
|
val cleaned = query.trim()
|
||||||
val term = URLEncoder.encode(cleaned, "UTF-8")
|
val term = URLEncoder.encode(cleaned, "UTF-8")
|
||||||
val words = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() }
|
val words = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||||
@@ -48,17 +55,19 @@ class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
|||||||
// Les trois critères sont indépendants : les lancer en parallèle ramène l'attente
|
// Les trois critères sont indépendants : les lancer en parallèle ramène l'attente
|
||||||
// à celle de la requête la plus lente, au lieu de leur somme.
|
// à celle de la requête la plus lente, au lieu de leur somme.
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
criteria.map { async { books(it) } }.awaitAll()
|
criteria.map { async { books(it, limit, language) } }.awaitAll()
|
||||||
}.flatten().distinctBy { it.id }
|
}.flatten().distinctBy { it.id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
|
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> =
|
||||||
rss.import(item.id).map { it.episodes }
|
rss.import(item.id).map { feed -> feed.episodes }
|
||||||
|
|
||||||
private fun books(criterion: String): List<CatalogItem> {
|
private fun books(criterion: String, limit: Int, language: String?): List<CatalogItem> {
|
||||||
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25&$criterion"
|
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=$limit&$criterion"
|
||||||
val connection = openConnection(url)
|
// Le service met 14 à 16 secondes à répondre, quelle que soit la taille demandée :
|
||||||
|
// c'est sa latence propre, pas le volume. D'où une patience adaptée.
|
||||||
|
val connection = openConnection(url, readTimeoutMs = 45_000)
|
||||||
val body = try {
|
val body = try {
|
||||||
when (connection.responseCode) {
|
when (connection.responseCode) {
|
||||||
// LibriVox répond 404 quand le critère ne donne rien : ce n'est pas une panne.
|
// LibriVox répond 404 quand le critère ne donne rien : ce n'est pas une panne.
|
||||||
@@ -73,6 +82,9 @@ class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
|||||||
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray ?: return emptyList()
|
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray ?: return emptyList()
|
||||||
return books.mapNotNull { element ->
|
return books.mapNotNull { element ->
|
||||||
val book = element as? JsonObject ?: return@mapNotNull null
|
val book = element as? JsonObject ?: return@mapNotNull null
|
||||||
|
if (language != null && !book.text("language").equals(language, ignoreCase = true)) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
val feed = book.text("url_rss") ?: return@mapNotNull null
|
val feed = book.text("url_rss") ?: return@mapNotNull null
|
||||||
CatalogItem(
|
CatalogItem(
|
||||||
id = feed,
|
id = feed,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package fr.tuxgyver.cachette.sources
|
||||||
|
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche de fichiers audio sur Wikimedia Commons.
|
||||||
|
*
|
||||||
|
* Retenu après avoir écarté deux candidats vérifiés : Openverse refuse désormais l'accès anonyme
|
||||||
|
* (401 sur huit requêtes sur huit) et ccMixter exige un en-tête `Referer` que Media3 ne permet pas
|
||||||
|
* de poser par téléchargement. Commons, lui, sert ses fichiers directement depuis
|
||||||
|
* upload.wikimedia.org, sans clé ni en-tête particulier, et annonce leur taille.
|
||||||
|
*
|
||||||
|
* Chaque résultat est déjà un fichier unique : la liste de pistes n'en contient donc qu'une.
|
||||||
|
*/
|
||||||
|
class WikimediaCatalog : Catalog {
|
||||||
|
|
||||||
|
override val id = CatalogId.WIKIMEDIA
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val search = URLEncoder.encode("filetype:audio ${query.trim()}", "UTF-8")
|
||||||
|
val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json" +
|
||||||
|
"&generator=search&gsrsearch=$search&gsrnamespace=6&gsrlimit=30" +
|
||||||
|
"&prop=imageinfo&iiprop=url%7Csize%7Cmime%7Cextmetadata"
|
||||||
|
|
||||||
|
val connection = openConnection(url, readTimeoutMs = 30_000)
|
||||||
|
val body = try {
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("Commons a répondu ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
connection.inputStream.bufferedReader().readText()
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
val pages = json.parseToJsonElement(body).jsonObject["query"]
|
||||||
|
?.jsonObject?.get("pages")?.jsonObject
|
||||||
|
?: return@runCatching emptyList()
|
||||||
|
|
||||||
|
pages.values.mapNotNull { element ->
|
||||||
|
val page = element as? JsonObject ?: return@mapNotNull null
|
||||||
|
val info = page["imageinfo"]?.jsonArray?.firstOrNull()?.jsonObject
|
||||||
|
?: return@mapNotNull null
|
||||||
|
val fileUrl = info.text("url") ?: return@mapNotNull null
|
||||||
|
if (!PLAYABLE.any { fileUrl.substringBefore('?').lowercase().endsWith(it) }) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
val licence = info["extmetadata"]?.jsonObject
|
||||||
|
?.get("LicenseShortName")?.jsonObject?.text("value")
|
||||||
|
|
||||||
|
CatalogItem(
|
||||||
|
id = fileUrl,
|
||||||
|
// Les titres Commons sont préfixés « File: » : inutile à l'écran.
|
||||||
|
title = page.text("title")?.removePrefix("File:")?.substringBeforeLast('.')
|
||||||
|
?: "Sans titre",
|
||||||
|
subtitle = "Wikimedia Commons",
|
||||||
|
hint = licence ?: "Domaine public / CC",
|
||||||
|
sizeBytes = info.text("size")?.toLongOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = Result.success(
|
||||||
|
listOf(
|
||||||
|
MediaEntry(
|
||||||
|
id = stableId("wm:", item.id),
|
||||||
|
source = SourceKind.URL,
|
||||||
|
kind = MediaKind.AUDIO,
|
||||||
|
title = item.title,
|
||||||
|
subtitle = item.subtitle,
|
||||||
|
uri = item.id,
|
||||||
|
addedAt = System.currentTimeMillis(),
|
||||||
|
sizeBytes = item.sizeBytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Formats que Media3 sait lire parmi ceux que Commons héberge. */
|
||||||
|
val PLAYABLE = listOf(".ogg", ".oga", ".opus", ".flac", ".wav", ".mp3", ".m4a")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.CloudDownload
|
import androidx.compose.material.icons.rounded.CloudDownload
|
||||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||||
|
import androidx.compose.material.icons.rounded.PlaylistPlay
|
||||||
import androidx.compose.material.icons.rounded.Tune
|
import androidx.compose.material.icons.rounded.Tune
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
@@ -39,10 +40,15 @@ import fr.tuxgyver.cachette.model.MediaEntry
|
|||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
import fr.tuxgyver.cachette.sources.CatalogId
|
import fr.tuxgyver.cachette.sources.CatalogId
|
||||||
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen
|
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen
|
||||||
|
import fr.tuxgyver.cachette.ui.browse.CatalogItemScreen
|
||||||
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen
|
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.PlaylistDetailScreen
|
||||||
|
import fr.tuxgyver.cachette.ui.playlists.PlaylistsScreen
|
||||||
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
|
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
|
||||||
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
|
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -50,11 +56,15 @@ import kotlinx.coroutines.launch
|
|||||||
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
||||||
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
|
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
|
||||||
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
|
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
|
||||||
|
PLAYLISTS("playlists", "Playlists", Icons.Rounded.PlaylistPlay),
|
||||||
SOURCES("sources", "Sources", Icons.Rounded.Tune),
|
SOURCES("sources", "Sources", Icons.Rounded.Tune),
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val BROWSE_ROUTE = "browse/{source}"
|
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 PLAYLIST_ROUTE = "playlist/{playlist}"
|
||||||
|
private const val RADIO_ROUTE = "radio"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CachetteRoot(container: AppContainer) {
|
fun CachetteRoot(container: AppContainer) {
|
||||||
@@ -63,6 +73,7 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
val currentRoute = backStackEntry?.destination?.route
|
val currentRoute = backStackEntry?.destination?.route
|
||||||
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var playlistTarget by remember { mutableStateOf<MediaEntry?>(null) }
|
||||||
|
|
||||||
val playerState by container.player.state.collectAsStateWithLifecycle()
|
val playerState by container.player.state.collectAsStateWithLifecycle()
|
||||||
val position by container.player.positionMs.collectAsStateWithLifecycle()
|
val position by container.player.positionMs.collectAsStateWithLifecycle()
|
||||||
@@ -111,6 +122,7 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
container = container,
|
container = container,
|
||||||
currentMediaId = playerState.mediaId,
|
currentMediaId = playerState.mediaId,
|
||||||
onPlay = play,
|
onPlay = play,
|
||||||
|
onAddToPlaylist = { playlistTarget = it },
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,6 +132,36 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
container = container,
|
container = container,
|
||||||
currentMediaId = playerState.mediaId,
|
currentMediaId = playerState.mediaId,
|
||||||
onPlay = play,
|
onPlay = play,
|
||||||
|
onAddToPlaylist = { playlistTarget = it },
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Tab.PLAYLISTS.route) {
|
||||||
|
PlaylistsScreen(
|
||||||
|
container = container,
|
||||||
|
onOpen = { navController.navigate("playlist/$it") },
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(RADIO_ROUTE) {
|
||||||
|
RadioScreen(
|
||||||
|
container = container,
|
||||||
|
currentMediaId = playerState.mediaId,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
|
onMessage = notify,
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(PLAYLIST_ROUTE) { entry ->
|
||||||
|
PlaylistDetailScreen(
|
||||||
|
container = container,
|
||||||
|
playlistId = entry.arguments?.getString("playlist").orEmpty(),
|
||||||
|
currentMediaId = playerState.mediaId,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
|
onPlay = play,
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +195,21 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
container = container,
|
container = container,
|
||||||
catalogId = catalog,
|
catalogId = catalog,
|
||||||
onBack = navController::popBackStack,
|
onBack = navController::popBackStack,
|
||||||
|
onOpenItem = { item ->
|
||||||
|
container.openedCatalogItem = item
|
||||||
|
navController.navigate("catalog/${catalog.name}/item")
|
||||||
|
},
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(CATALOG_ITEM_ROUTE) { entry ->
|
||||||
|
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
|
||||||
|
CatalogItemScreen(
|
||||||
|
container = container,
|
||||||
|
catalogId = catalog,
|
||||||
|
currentMediaId = playerState.mediaId,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
onMessage = notify,
|
onMessage = notify,
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
@@ -177,12 +235,26 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
onSkipBy = container.player::skipBy,
|
onSkipBy = container.player::skipBy,
|
||||||
onNext = container.player::next,
|
onNext = container.player::next,
|
||||||
onPrevious = container.player::previous,
|
onPrevious = container.player::previous,
|
||||||
|
onStop = {
|
||||||
|
container.player.stop()
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
onAddToPlaylist = { entry?.let { playlistTarget = it } },
|
||||||
onDownload = { entry?.let(container.downloads::download) },
|
onDownload = { entry?.let(container.downloads::download) },
|
||||||
onRemoveOffline = { playerState.mediaId?.let(container.downloads::remove) },
|
onRemoveOffline = { playerState.mediaId?.let(container.downloads::remove) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playlistTarget?.let { target ->
|
||||||
|
AddToPlaylistSheet(
|
||||||
|
container = container,
|
||||||
|
entry = target,
|
||||||
|
onDismiss = { playlistTarget = null },
|
||||||
|
onMessage = notify,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = expanded) { expanded = false }
|
BackHandler(enabled = expanded) { expanded = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package fr.tuxgyver.cachette.ui.browse
|
package fr.tuxgyver.cachette.ui.browse
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -14,15 +15,17 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -53,10 +56,10 @@ import fr.tuxgyver.cachette.ui.components.formatBytes
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recherche dans un catalogue libre de droits, puis ajout de la sélection à la bibliothèque.
|
* Recherche dans un catalogue libre de droits.
|
||||||
*
|
*
|
||||||
* L'ajout déclenche aussi la mise en cache : on ne parcourt pas un catalogue pour regarder
|
* Un résultat n'est pas un fichier mais un album, un livre ou un morceau : l'ouvrir mène à la
|
||||||
* passer les titres, mais pour les emporter.
|
* liste de ses pistes, où l'on écoute et où l'on choisit ce qu'on emporte.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -64,17 +67,16 @@ fun CatalogBrowseScreen(
|
|||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
catalogId: CatalogId,
|
catalogId: CatalogId,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onMessage: (String) -> Unit,
|
onOpenItem: (CatalogItem) -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
) {
|
) {
|
||||||
val catalog = container.catalogs.getValue(catalogId)
|
val catalog = container.catalogs.getValue(catalogId)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
|
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
|
||||||
|
var language by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
|
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
|
||||||
var selection by remember { mutableStateOf(emptySet<String>()) }
|
|
||||||
var searching by remember { mutableStateOf(false) }
|
var searching by remember { mutableStateOf(false) }
|
||||||
var adding by remember { mutableStateOf(false) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
fun runSearch() {
|
fun runSearch() {
|
||||||
@@ -82,7 +84,7 @@ fun CatalogBrowseScreen(
|
|||||||
searching = true
|
searching = true
|
||||||
error = null
|
error = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
catalog.search(query).fold(
|
catalog.search(query, language).fold(
|
||||||
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
|
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
|
||||||
onFailure = { error = it.message ?: "Recherche impossible" },
|
onFailure = { error = it.message ?: "Recherche impossible" },
|
||||||
)
|
)
|
||||||
@@ -90,133 +92,105 @@ fun CatalogBrowseScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(catalogId) { runSearch() }
|
LaunchedEffect(catalogId, language) { runSearch() }
|
||||||
|
|
||||||
// La barre de titre occupe déjà l'espace haut du Scaffold : la liste ne reprend que le bas.
|
|
||||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
TopAppBar(
|
||||||
TopAppBar(
|
title = { Text(catalogId.title) },
|
||||||
title = { Text(catalogId.title) },
|
navigationIcon = {
|
||||||
navigationIcon = {
|
IconButton(onClick = onBack) {
|
||||||
IconButton(onClick = onBack) {
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
}
|
||||||
}
|
},
|
||||||
},
|
)
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = catalogId.license,
|
text = catalogId.license,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = { query = it },
|
onValueChange = { query = it },
|
||||||
placeholder = { Text("Rechercher dans ${catalogId.title}") },
|
placeholder = { Text("Rechercher dans ${catalogId.title}") },
|
||||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
|
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (catalog.languages.isNotEmpty()) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.horizontalScroll(rememberScrollState())
|
||||||
)
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
when {
|
FilterChip(
|
||||||
searching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
selected = language == null,
|
||||||
CircularProgressIndicator()
|
onClick = { language = null },
|
||||||
}
|
label = { Text("Toutes langues") },
|
||||||
|
)
|
||||||
error != null -> Box(
|
catalog.languages.forEach { option ->
|
||||||
Modifier
|
FilterChip(
|
||||||
.fillMaxSize()
|
selected = language == option.code,
|
||||||
.padding(32.dp),
|
onClick = { language = option.code },
|
||||||
contentAlignment = Alignment.Center,
|
label = { Text(option.label) },
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = error!!,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
else -> LazyColumn(
|
when {
|
||||||
contentPadding = listPadding,
|
searching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
CircularProgressIndicator()
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
}
|
||||||
) {
|
|
||||||
items(results, key = { it.id }) { item ->
|
error != null -> Box(
|
||||||
CatalogRow(
|
Modifier
|
||||||
item = item,
|
.fillMaxSize()
|
||||||
selected = item.id in selection,
|
.padding(32.dp),
|
||||||
onToggle = {
|
contentAlignment = Alignment.Center,
|
||||||
selection = if (item.id in selection) selection - item.id
|
) {
|
||||||
else selection + item.id
|
Text(
|
||||||
},
|
text = error!!,
|
||||||
)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
}
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
item { Spacer(Modifier.size(88.dp)) }
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> LazyColumn(
|
||||||
|
contentPadding = listPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(results, key = { it.id }) { item ->
|
||||||
|
CatalogRow(item = item, onOpen = { onOpenItem(item) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectionBar(
|
|
||||||
count = selection.size,
|
|
||||||
actionLabel = "Ajouter et garder",
|
|
||||||
busy = adding,
|
|
||||||
onClear = { selection = emptySet() },
|
|
||||||
onConfirm = {
|
|
||||||
adding = true
|
|
||||||
scope.launch {
|
|
||||||
val chosen = results.filter { it.id in selection }
|
|
||||||
var added = 0
|
|
||||||
var failed = 0
|
|
||||||
chosen.forEach { item ->
|
|
||||||
catalog.resolve(item).fold(
|
|
||||||
onSuccess = { entries ->
|
|
||||||
container.imported.add(entries)
|
|
||||||
entries.forEach(container.downloads::download)
|
|
||||||
added += entries.size
|
|
||||||
},
|
|
||||||
onFailure = { failed++ },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
adding = false
|
|
||||||
selection = emptySet()
|
|
||||||
onMessage(
|
|
||||||
buildString {
|
|
||||||
append("$added élément(s) ajoutés et mis en cache")
|
|
||||||
if (failed > 0) append(" · $failed indisponible(s)")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(contentPadding),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CatalogRow(
|
private fun CatalogRow(item: CatalogItem, onOpen: () -> Unit) {
|
||||||
item: CatalogItem,
|
|
||||||
selected: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
) {
|
|
||||||
Surface(
|
Surface(
|
||||||
color = if (selected) MaterialTheme.colorScheme.secondaryContainer
|
color = MaterialTheme.colorScheme.surface,
|
||||||
else MaterialTheme.colorScheme.surface,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onToggle),
|
.clickable(onClick = onOpen),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -225,7 +199,7 @@ private fun CatalogRow(
|
|||||||
Artwork(
|
Artwork(
|
||||||
uri = item.artworkUri,
|
uri = item.artworkUri,
|
||||||
title = item.title,
|
title = item.title,
|
||||||
kind = if (item.hint == "Vidéo") MediaKind.VIDEO else MediaKind.AUDIO,
|
kind = if (item.hint.contains("Vidéo")) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
)
|
)
|
||||||
@@ -249,7 +223,11 @@ private fun CatalogRow(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Checkbox(checked = selected, onCheckedChange = { onToggle() })
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||||
|
contentDescription = "Voir les pistes",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.browse
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.GraphicEq
|
||||||
|
import androidx.compose.material.icons.rounded.PlayCircleOutline
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import fr.tuxgyver.cachette.AppContainer
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.sources.CatalogId
|
||||||
|
import fr.tuxgyver.cachette.ui.components.formatBytes
|
||||||
|
import fr.tuxgyver.cachette.ui.components.formatDuration
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les pistes d'un résultat de catalogue.
|
||||||
|
*
|
||||||
|
* Un album ou un livre en compte souvent des dizaines : chacune s'écoute d'abord en ligne, et
|
||||||
|
* n'est mise en cache que si on la coche. La taille de la sélection est annoncée avant l'envoi.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CatalogItemScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
catalogId: CatalogId,
|
||||||
|
currentMediaId: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val item = container.openedCatalogItem
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var tracks by remember { mutableStateOf<List<MediaEntry>>(emptyList()) }
|
||||||
|
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var adding by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(item?.id) {
|
||||||
|
val opened = item ?: run {
|
||||||
|
onBack()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
loading = true
|
||||||
|
container.catalogs.getValue(catalogId).tracks(opened).fold(
|
||||||
|
onSuccess = { tracks = it; error = if (it.isEmpty()) "Aucune piste lisible" else null },
|
||||||
|
onFailure = { error = it.message ?: "Contenu illisible" },
|
||||||
|
)
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectedSize = remember(selection, tracks) {
|
||||||
|
tracks.filter { it.id in selection }.sumOf { it.sizeBytes ?: 0L }
|
||||||
|
}
|
||||||
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = item?.title ?: "Pistes",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (tracks.isNotEmpty()) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
selection = if (selection.size == tracks.size) emptySet()
|
||||||
|
else tracks.mapTo(mutableSetOf()) { it.id }
|
||||||
|
}) {
|
||||||
|
Text(if (selection.size == tracks.size) "Aucune" else "Toutes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tracks.isNotEmpty()) {
|
||||||
|
val total = tracks.sumOf { it.sizeBytes ?: 0L }
|
||||||
|
Text(
|
||||||
|
text = buildString {
|
||||||
|
append("${tracks.size} piste(s)")
|
||||||
|
if (total > 0) append(" · ${formatBytes(total)} au total")
|
||||||
|
append(" · appuyez pour écouter en ligne")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.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(4.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(tracks, key = { it.id }) { track ->
|
||||||
|
TrackRow(
|
||||||
|
track = track,
|
||||||
|
playing = track.id == currentMediaId,
|
||||||
|
selected = track.id in selection,
|
||||||
|
onPreview = {
|
||||||
|
// Un aperçu ne rejoint pas la bibliothèque : on le confie au
|
||||||
|
// registre transitoire pour que la lecture sache le résoudre.
|
||||||
|
container.library.preview(tracks)
|
||||||
|
container.player.play(tracks, tracks.indexOf(track))
|
||||||
|
},
|
||||||
|
onToggle = {
|
||||||
|
selection = if (track.id in selection) selection - track.id
|
||||||
|
else selection + track.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(88.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionBar(
|
||||||
|
count = selection.size,
|
||||||
|
actionLabel = if (selectedSize > 0) "Garder ${formatBytes(selectedSize)}" else "Garder",
|
||||||
|
busy = adding,
|
||||||
|
onClear = { selection = emptySet() },
|
||||||
|
onConfirm = {
|
||||||
|
adding = true
|
||||||
|
scope.launch {
|
||||||
|
val chosen = tracks.filter { it.id in selection }
|
||||||
|
val added = container.imported.add(chosen)
|
||||||
|
chosen.forEach(container.downloads::download)
|
||||||
|
adding = false
|
||||||
|
selection = emptySet()
|
||||||
|
onMessage(
|
||||||
|
if (added == 0) "Ces pistes sont déjà dans la bibliothèque"
|
||||||
|
else "$added piste(s) ajoutées, téléchargement lancé"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(contentPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackRow(
|
||||||
|
track: MediaEntry,
|
||||||
|
playing: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
onPreview: () -> Unit,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = when {
|
||||||
|
playing -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onPreview),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
|
||||||
|
contentDescription = if (playing) "En lecture" else "Écouter en ligne",
|
||||||
|
tint = if (playing) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = track.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = listOfNotNull(
|
||||||
|
track.durationMs.takeIf { it > 0 }?.let(::formatDuration),
|
||||||
|
track.sizeBytes?.takeIf { it > 0 }?.let(::formatBytes),
|
||||||
|
).joinToString(" · ").ifBlank { "Taille inconnue" },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Checkbox(checked = selected, onCheckedChange = { onToggle() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package fr.tuxgyver.cachette.ui.components
|
package fr.tuxgyver.cachette.ui.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.offline.OfflineState
|
import fr.tuxgyver.cachette.offline.OfflineState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaRow(
|
fun MediaRow(
|
||||||
entry: MediaEntry,
|
entry: MediaEntry,
|
||||||
@@ -45,6 +47,8 @@ fun MediaRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
/** Non nul en mode sélection : la ligne porte alors une case à cocher au lieu de son action. */
|
/** Non nul en mode sélection : la ligne porte alors une case à cocher au lieu de son action. */
|
||||||
selected: Boolean? = null,
|
selected: Boolean? = null,
|
||||||
|
/** Appui long : ajout à une playlist. Absent là où le geste n'a pas de sens. */
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val background by animateColorAsState(
|
val background by animateColorAsState(
|
||||||
targetValue = when {
|
targetValue = when {
|
||||||
@@ -60,7 +64,7 @@ fun MediaRow(
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick),
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ fun LibraryScreen(
|
|||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
currentMediaId: String?,
|
currentMediaId: String?,
|
||||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||||
|
onAddToPlaylist: (MediaEntry) -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
) {
|
) {
|
||||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
@@ -169,6 +170,7 @@ fun LibraryScreen(
|
|||||||
onClick = { onPlay(visible, visible.indexOf(entry)) },
|
onClick = { onPlay(visible, visible.indexOf(entry)) },
|
||||||
onDownload = { container.downloads.download(entry) },
|
onDownload = { container.downloads.download(entry) },
|
||||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
|
onLongClick = { onAddToPlaylist(entry) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ fun DownloadsScreen(
|
|||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
currentMediaId: String?,
|
currentMediaId: String?,
|
||||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||||
|
onAddToPlaylist: (MediaEntry) -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
) {
|
) {
|
||||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
@@ -98,6 +99,7 @@ fun DownloadsScreen(
|
|||||||
onClick = { onPlay(cached, cached.indexOf(entry)) },
|
onClick = { onPlay(cached, cached.indexOf(entry)) },
|
||||||
onDownload = { container.downloads.download(entry) },
|
onDownload = { container.downloads.download(entry) },
|
||||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
|
onLongClick = { onAddToPlaylist(entry) },
|
||||||
)
|
)
|
||||||
if (state?.isActive == true) {
|
if (state?.isActive == true) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package fr.tuxgyver.cachette.ui.player
|
|||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.basicMarquee
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -19,11 +20,13 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.CheckCircle
|
import androidx.compose.material.icons.rounded.CheckCircle
|
||||||
import androidx.compose.material.icons.rounded.Download
|
import androidx.compose.material.icons.rounded.Download
|
||||||
import androidx.compose.material.icons.rounded.Forward30
|
import androidx.compose.material.icons.rounded.FastForward
|
||||||
|
import androidx.compose.material.icons.rounded.FastRewind
|
||||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.rounded.Pause
|
import androidx.compose.material.icons.rounded.Pause
|
||||||
|
import androidx.compose.material.icons.rounded.PlaylistAdd
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
import androidx.compose.material.icons.rounded.Replay10
|
import androidx.compose.material.icons.rounded.Stop
|
||||||
import androidx.compose.material.icons.rounded.SkipNext
|
import androidx.compose.material.icons.rounded.SkipNext
|
||||||
import androidx.compose.material.icons.rounded.SkipPrevious
|
import androidx.compose.material.icons.rounded.SkipPrevious
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -71,6 +74,8 @@ fun NowPlayingScreen(
|
|||||||
onSkipBy: (Long) -> Unit,
|
onSkipBy: (Long) -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
|
onStop: () -> Unit,
|
||||||
|
onAddToPlaylist: () -> Unit,
|
||||||
onDownload: () -> Unit,
|
onDownload: () -> Unit,
|
||||||
onRemoveOffline: () -> Unit,
|
onRemoveOffline: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -112,6 +117,12 @@ fun NowPlayingScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = onAddToPlaylist) {
|
||||||
|
Icon(Icons.Rounded.PlaylistAdd, contentDescription = "Ajouter à une playlist")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onStop) {
|
||||||
|
Icon(Icons.Rounded.Stop, contentDescription = "Arrêter la lecture")
|
||||||
|
}
|
||||||
OfflineToggle(entry, offline, onDownload, onRemoveOffline)
|
OfflineToggle(entry, offline, onDownload, onRemoveOffline)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,9 +201,12 @@ fun NowPlayingScreen(
|
|||||||
IconButton(onClick = onPrevious, enabled = state.hasPrevious) {
|
IconButton(onClick = onPrevious, enabled = state.hasPrevious) {
|
||||||
Icon(Icons.Rounded.SkipPrevious, contentDescription = "Précédent")
|
Icon(Icons.Rounded.SkipPrevious, contentDescription = "Précédent")
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onSkipBy(-10_000) }) {
|
SkipButton(
|
||||||
Icon(Icons.Rounded.Replay10, contentDescription = "Reculer de 10 secondes")
|
icon = Icons.Rounded.FastRewind,
|
||||||
}
|
label = "15 s",
|
||||||
|
description = "Reculer de 15 secondes",
|
||||||
|
onClick = { onSkipBy(-15_000) },
|
||||||
|
)
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = onTogglePlay,
|
onClick = onTogglePlay,
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
@@ -213,9 +227,12 @@ fun NowPlayingScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onSkipBy(30_000) }) {
|
SkipButton(
|
||||||
Icon(Icons.Rounded.Forward30, contentDescription = "Avancer de 30 secondes")
|
icon = Icons.Rounded.FastForward,
|
||||||
}
|
label = "15 s",
|
||||||
|
description = "Avancer de 15 secondes",
|
||||||
|
onClick = { onSkipBy(15_000) },
|
||||||
|
)
|
||||||
IconButton(onClick = onNext, enabled = state.hasNext) {
|
IconButton(onClick = onNext, enabled = state.hasNext) {
|
||||||
Icon(Icons.Rounded.SkipNext, contentDescription = "Suivant")
|
Icon(Icons.Rounded.SkipNext, contentDescription = "Suivant")
|
||||||
}
|
}
|
||||||
@@ -226,6 +243,27 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Material ne fournit pas d'icône « 15 » : le pas de saut est donc écrit sous l'icône. */
|
||||||
|
@Composable
|
||||||
|
private fun SkipButton(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
label: String,
|
||||||
|
description: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = description)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OfflineToggle(
|
private fun OfflineToggle(
|
||||||
entry: MediaEntry?,
|
entry: MediaEntry?,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.playlists
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Add
|
||||||
|
import androidx.compose.material.icons.rounded.PlaylistPlay
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.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 kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Choix de la playlist d'accueil, avec création à la volée si aucune ne convient. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AddToPlaylistSheet(
|
||||||
|
container: AppContainer,
|
||||||
|
entry: MediaEntry,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var newName by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
fun addTo(playlistId: String, name: String) {
|
||||||
|
scope.launch {
|
||||||
|
container.playlists.add(playlistId, listOf(entry.id))
|
||||||
|
onMessage("« ${entry.title} » ajouté à $name")
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
Text("Ajouter à une playlist", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
|
||||||
|
if (playlists.isNotEmpty()) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.heightIn(max = 280.dp),
|
||||||
|
) {
|
||||||
|
items(playlists, key = { it.id }) { playlist ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { addTo(playlist.id, playlist.name) }
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.PlaylistPlay, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column {
|
||||||
|
Text(playlist.name, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
text = "${playlist.entryIds.size} titre(s)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newName,
|
||||||
|
onValueChange = { newName = it },
|
||||||
|
placeholder = { Text("Nouvelle playlist") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
TextButton(
|
||||||
|
enabled = newName.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val created = container.playlists.create(newName)
|
||||||
|
container.playlists.add(created.id, listOf(entry.id))
|
||||||
|
onMessage("« ${entry.title} » ajouté à ${created.name}")
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Créer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.playlists
|
||||||
|
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
|
import androidx.compose.material.icons.rounded.RemoveCircleOutline
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.ui.components.MediaRow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Le contenu d'une playlist, dans l'ordre d'ajout. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PlaylistDetailScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
playlistId: String,
|
||||||
|
currentMediaId: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
||||||
|
val library by container.library.entries.collectAsStateWithLifecycle()
|
||||||
|
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val playlist = playlists.firstOrNull { it.id == playlistId }
|
||||||
|
|
||||||
|
// Un identifiant sans média correspondant est ignoré : la bibliothèque reste la référence.
|
||||||
|
val tracks = remember(playlist, library) {
|
||||||
|
playlist?.entryIds?.mapNotNull { id -> library.firstOrNull { it.id == id } }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = playlist?.name ?: "Playlist",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tracks.isNotEmpty()) {
|
||||||
|
Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||||
|
Button(onClick = { onPlay(tracks, 0) }) {
|
||||||
|
Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("Tout lire")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Cette playlist est vide. Appuyez longuement sur un média de la bibliothèque pour l'y ajouter.",
|
||||||
|
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(tracks, key = { it.id }) { entry ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
MediaRow(
|
||||||
|
entry = entry,
|
||||||
|
offline = offlineStates[entry.id],
|
||||||
|
isCurrent = entry.id == currentMediaId,
|
||||||
|
onClick = { onPlay(tracks, tracks.indexOf(entry)) },
|
||||||
|
onDownload = { container.downloads.download(entry) },
|
||||||
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch { container.playlists.remove(playlistId, entry.id) }
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.RemoveCircleOutline,
|
||||||
|
contentDescription = "Retirer de la playlist",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.playlists
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Add
|
||||||
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
|
import androidx.compose.material.icons.rounded.PlaylistPlay
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
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.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.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 kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistsScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
onOpen: (String) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var creating by remember { mutableStateOf(false) }
|
||||||
|
var pendingDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(Modifier.padding(top = 12.dp)) {
|
||||||
|
Text("Playlists", style = MaterialTheme.typography.displaySmall)
|
||||||
|
Text(
|
||||||
|
text = "${playlists.size} liste(s)",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(14.dp))
|
||||||
|
Button(onClick = { creating = true }) {
|
||||||
|
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("Nouvelle playlist")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlists.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.PlaylistPlay,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Text("Aucune playlist", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Appuyez longuement sur un média de la bibliothèque pour l'ajouter à une liste.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(playlists, key = { it.id }) { playlist ->
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
tonalElevation = 1.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpen(playlist.id) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.PlaylistPlay,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = playlist.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${playlist.entryIds.size} titre(s)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { pendingDelete = playlist.id }) {
|
||||||
|
Icon(Icons.Rounded.Delete, contentDescription = "Supprimer la playlist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creating) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { creating = false },
|
||||||
|
title = { Text("Nouvelle playlist") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
placeholder = { Text("Nom de la liste") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = name.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
scope.launch { container.playlists.create(name) }
|
||||||
|
creating = false
|
||||||
|
},
|
||||||
|
) { Text("Créer") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { creating = false }) { Text("Annuler") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDelete?.let { id ->
|
||||||
|
val name = container.playlists.playlist(id)?.name.orEmpty()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingDelete = null },
|
||||||
|
title = { Text("Supprimer « $name » ?") },
|
||||||
|
text = { Text("La liste disparaît. Les médias et les fichiers mis en cache restent.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
scope.launch { container.playlists.delete(id) }
|
||||||
|
pendingDelete = null
|
||||||
|
}) { Text("Supprimer") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { pendingDelete = null }) { Text("Annuler") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||||
|
import androidx.compose.material.icons.rounded.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
|
||||||
@@ -58,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) }
|
||||||
@@ -121,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)
|
||||||
@@ -133,7 +149,11 @@ fun SourcesScreen(
|
|||||||
|
|
||||||
items(CatalogId.entries) { catalog ->
|
items(CatalogId.entries) { catalog ->
|
||||||
SourceCard(
|
SourceCard(
|
||||||
icon = if (catalog == CatalogId.LIBRIVOX) Icons.AutoMirrored.Rounded.MenuBook else Icons.Rounded.Public,
|
icon = when (catalog) {
|
||||||
|
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
|
||||||
|
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
|
||||||
|
else -> Icons.Rounded.Public
|
||||||
|
},
|
||||||
title = catalog.title,
|
title = catalog.title,
|
||||||
status = catalog.license,
|
status = catalog.license,
|
||||||
description = catalog.description,
|
description = catalog.description,
|
||||||
|
|||||||
Reference in new issue
Block a user