Choix des pistes, écoute en ligne, playlists et source Wikimedia (1.2.0)
Un album ou un livre se téléchargeait en aveugle : l'application retenait une piste arbitraire sans jamais dire laquelle ni ce qu'elle pesait. La fiche d'un résultat liste désormais toutes ses pistes avec durée et taille, le total est annoncé, et le bouton chiffre ce qu'on s'apprête à emporter. Chaque piste s'essaie d'abord en streaming, sans rien mettre en cache. Les playlists manquaient pour organiser ce qu'on garde : listes nommées, ajout par appui long, lecture enchaînée. Les contrôles se complètent d'un arrêt et de sauts de quinze secondes. Le filtre de langue de LibriVox ne pouvait pas marcher côté serveur : l'API accepte le paramètre puis l'ignore, « language=French » seul renvoyant des livres anglais. Le tri se fait sur le champ de chaque livre. Les délais réseau suivent la latence réellement mesurée des services, LibriVox répondant en quatorze à seize secondes quelle que soit la taille demandée. Wikimedia Commons rejoint les catalogues libres. Openverse et ccMixter ont été essayés puis écartés : le premier refuse l'accès anonyme, le second exige un en-tête Referer que Media3 ne sait pas poser par téléchargement.
This commit is contained in:
1 parent
c1912c2056
commit
2dabfdbc84
24 files changed
+1311
-177
No files matched your search
@@ -5,6 +5,37 @@ Toutes les évolutions notables de Cachette sont documentées ici.
|
||||
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
||||
|
||||
## [1.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
|
||||
|
||||
### Ajouté
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/jetpack/compose)
|
||||
[](https://developer.android.com/media/media3)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
[](#-comment-ça-marche)
|
||||
|
||||
Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
|
||||
@@ -30,11 +30,16 @@ 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 |
|
||||
| 📥 **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 |
|
||||
| 🎧 **É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 |
|
||||
| 📻 **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é |
|
||||
| 📱 **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é |
|
||||
| ⏯️ **Contrôles complets** | Lecture, pause, arrêt, piste précédente et suivante, ±15 s |
|
||||
| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti |
|
||||
| 🔁 **Reprise automatique** | Un téléchargement coupé repart tout seul via WorkManager |
|
||||
| 🆕 **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 +54,16 @@ sans la moindre connexion.
|
||||
| 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo |
|
||||
| 📻 Podcast RSS | ✅ | Flux standard avec `<enclosure>` |
|
||||
| 🏛️ 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 |
|
||||
|
||||
### 🔎 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
|
||||
|
||||
@@ -217,7 +231,8 @@ app/src/main/java/fr/tuxgyver/cachette/
|
||||
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
||||
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
|
||||
├── 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -5,12 +5,15 @@ import android.content.Context
|
||||
import fr.tuxgyver.cachette.library.ImportedStore
|
||||
import fr.tuxgyver.cachette.library.LibraryRepository
|
||||
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
||||
import fr.tuxgyver.cachette.library.PlaylistStore
|
||||
import fr.tuxgyver.cachette.offline.DownloadTracker
|
||||
import fr.tuxgyver.cachette.offline.OfflineModule
|
||||
import fr.tuxgyver.cachette.playback.PlayerController
|
||||
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
|
||||
import fr.tuxgyver.cachette.sources.Catalog
|
||||
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.RssImporter
|
||||
import fr.tuxgyver.cachette.sources.UrlImporter
|
||||
@@ -35,6 +38,7 @@ class AppContainer(context: Context) {
|
||||
|
||||
val imported = ImportedStore(context, scope)
|
||||
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||
val playlists = PlaylistStore(context, scope)
|
||||
|
||||
val urlImporter = UrlImporter()
|
||||
val rssImporter = RssImporter()
|
||||
@@ -42,8 +46,15 @@ class AppContainer(context: Context) {
|
||||
val catalogs: Map<CatalogId, Catalog> = listOf(
|
||||
ArchiveOrgCatalog(),
|
||||
LibriVoxCatalog(rssImporter),
|
||||
WikimediaCatalog(),
|
||||
).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 updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
|
||||
|
||||
@@ -39,7 +39,18 @@ class LibraryRepository(
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ data class MediaEntry(
|
||||
val artworkUri: String? = null,
|
||||
val durationMs: 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. */
|
||||
val isRemote: Boolean get() = source != SourceKind.LOCAL
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -23,14 +23,19 @@ class ArchiveOrgCatalog : Catalog {
|
||||
|
||||
override val id = CatalogId.ARCHIVE
|
||||
|
||||
override val languages = COMMON_LANGUAGES
|
||||
|
||||
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 {
|
||||
// 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.
|
||||
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
|
||||
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",
|
||||
)
|
||||
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 {
|
||||
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 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é
|
||||
// compressé : c'est celui qu'on veut sur un téléphone.
|
||||
val playable = files.mapNotNull { it as? JsonObject }
|
||||
.filter { file ->
|
||||
val name = file.text("name").orEmpty().lowercase()
|
||||
extensions.any { name.endsWith(it) }
|
||||
}
|
||||
.minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE }
|
||||
?: throw IllegalStateException(
|
||||
val playable = files.filter { file ->
|
||||
val name = file.text("name").orEmpty().lowercase()
|
||||
extensions.any { name.endsWith(it) }
|
||||
}
|
||||
if (playable.isEmpty()) {
|
||||
throw IllegalStateException(
|
||||
"Aucun fichier ${if (wantsVideo) "vidéo" else "audio"} lisible dans « ${item.title} »"
|
||||
)
|
||||
}
|
||||
|
||||
val name = playable.text("name")!!
|
||||
val seconds = playable.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
||||
|
||||
listOf(
|
||||
MediaEntry(
|
||||
id = stableId("ia:", "${item.id}/$name"),
|
||||
source = SourceKind.URL,
|
||||
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
uri = "https://archive.org/download/${item.id}/${encodePath(name)}",
|
||||
artworkUri = item.artworkUri,
|
||||
durationMs = seconds * 1000,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
// Un élément expose l'original et ses dérivés du même morceau. On regroupe par nom de
|
||||
// base et on garde le plus léger de chaque groupe : autant de pistes que d'œuvres,
|
||||
// pas autant que de formats.
|
||||
playable.groupBy { it.text("name")!!.substringBeforeLast('.').lowercase() }
|
||||
.values
|
||||
.mapNotNull { variants ->
|
||||
variants.minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE }
|
||||
}
|
||||
.sortedBy { it.text("name") }
|
||||
.map { file ->
|
||||
val name = file.text("name")!!
|
||||
val seconds = file.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
||||
MediaEntry(
|
||||
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
|
||||
|
||||
/** 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(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val artworkUri: String? = null,
|
||||
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,
|
||||
)
|
||||
|
||||
/** 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. */
|
||||
interface Catalog {
|
||||
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
|
||||
* seule : la résolution est faite ici, à la sélection, et pas pendant la recherche.
|
||||
* Les pistes d'un résultat, avec leur taille.
|
||||
*
|
||||
* 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(
|
||||
@@ -44,4 +52,20 @@ enum class CatalogId(
|
||||
license = "Domaine public",
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
requestMethod = method
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 20_000
|
||||
setRequestProperty("User-Agent", "Cachette/1.0 (Android)")
|
||||
readTimeout = readTimeoutMs
|
||||
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 languages = COMMON_LANGUAGES
|
||||
|
||||
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 {
|
||||
// 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 term = URLEncoder.encode(cleaned, "UTF-8")
|
||||
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
|
||||
// à celle de la requête la plus lente, au lieu de leur somme.
|
||||
coroutineScope {
|
||||
criteria.map { async { books(it) } }.awaitAll()
|
||||
criteria.map { async { books(it, limit, language) } }.awaitAll()
|
||||
}.flatten().distinctBy { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
|
||||
rss.import(item.id).map { it.episodes }
|
||||
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> =
|
||||
rss.import(item.id).map { feed -> feed.episodes }
|
||||
|
||||
private fun books(criterion: String): List<CatalogItem> {
|
||||
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25&$criterion"
|
||||
val connection = openConnection(url)
|
||||
private fun books(criterion: String, limit: Int, language: String?): List<CatalogItem> {
|
||||
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=$limit&$criterion"
|
||||
// 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 {
|
||||
when (connection.responseCode) {
|
||||
// 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()
|
||||
return books.mapNotNull { element ->
|
||||
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
|
||||
CatalogItem(
|
||||
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.rounded.CloudDownload
|
||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||
import androidx.compose.material.icons.rounded.PlaylistPlay
|
||||
import androidx.compose.material.icons.rounded.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
@@ -39,10 +40,14 @@ import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
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.library.LibraryScreen
|
||||
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
|
||||
import fr.tuxgyver.cachette.ui.player.MiniPlayer
|
||||
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.sources.SourcesScreen
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -50,11 +55,14 @@ import kotlinx.coroutines.launch
|
||||
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
||||
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
|
||||
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
|
||||
PLAYLISTS("playlists", "Playlists", Icons.Rounded.PlaylistPlay),
|
||||
SOURCES("sources", "Sources", Icons.Rounded.Tune),
|
||||
}
|
||||
|
||||
private const val BROWSE_ROUTE = "browse/{source}"
|
||||
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
||||
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
|
||||
private const val PLAYLIST_ROUTE = "playlist/{playlist}"
|
||||
|
||||
@Composable
|
||||
fun CachetteRoot(container: AppContainer) {
|
||||
@@ -63,6 +71,7 @@ fun CachetteRoot(container: AppContainer) {
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var playlistTarget by remember { mutableStateOf<MediaEntry?>(null) }
|
||||
|
||||
val playerState by container.player.state.collectAsStateWithLifecycle()
|
||||
val position by container.player.positionMs.collectAsStateWithLifecycle()
|
||||
@@ -111,6 +120,7 @@ fun CachetteRoot(container: AppContainer) {
|
||||
container = container,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onPlay = play,
|
||||
onAddToPlaylist = { playlistTarget = it },
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
@@ -120,6 +130,26 @@ fun CachetteRoot(container: AppContainer) {
|
||||
container = container,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onPlay = play,
|
||||
onAddToPlaylist = { playlistTarget = it },
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Tab.PLAYLISTS.route) {
|
||||
PlaylistsScreen(
|
||||
container = container,
|
||||
onOpen = { navController.navigate("playlist/$it") },
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -152,6 +182,21 @@ fun CachetteRoot(container: AppContainer) {
|
||||
container = container,
|
||||
catalogId = catalog,
|
||||
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,
|
||||
contentPadding = padding,
|
||||
)
|
||||
@@ -177,12 +222,26 @@ fun CachetteRoot(container: AppContainer) {
|
||||
onSkipBy = container.player::skipBy,
|
||||
onNext = container.player::next,
|
||||
onPrevious = container.player::previous,
|
||||
onStop = {
|
||||
container.player.stop()
|
||||
expanded = false
|
||||
},
|
||||
onAddToPlaylist = { entry?.let { playlistTarget = it } },
|
||||
onDownload = { entry?.let(container.downloads::download) },
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package fr.tuxgyver.cachette.ui.browse
|
||||
|
||||
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
|
||||
@@ -14,15 +15,17 @@ 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.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.Checkbox
|
||||
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
|
||||
@@ -53,10 +56,10 @@ import fr.tuxgyver.cachette.ui.components.formatBytes
|
||||
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
|
||||
* passer les titres, mais pour les emporter.
|
||||
* Un résultat n'est pas un fichier mais un album, un livre ou un morceau : l'ouvrir mène à la
|
||||
* liste de ses pistes, où l'on écoute et où l'on choisit ce qu'on emporte.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -64,17 +67,16 @@ fun CatalogBrowseScreen(
|
||||
container: AppContainer,
|
||||
catalogId: CatalogId,
|
||||
onBack: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
onOpenItem: (CatalogItem) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val catalog = container.catalogs.getValue(catalogId)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
|
||||
var language by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
|
||||
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun runSearch() {
|
||||
@@ -82,7 +84,7 @@ fun CatalogBrowseScreen(
|
||||
searching = true
|
||||
error = null
|
||||
scope.launch {
|
||||
catalog.search(query).fold(
|
||||
catalog.search(query, language).fold(
|
||||
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
|
||||
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())
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(catalogId.title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(catalogId.title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = catalogId.license,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Text(
|
||||
text = catalogId.license,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Rechercher dans ${catalogId.title}") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Rechercher dans ${catalogId.title}") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
|
||||
if (catalog.languages.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
|
||||
when {
|
||||
searching -> 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,
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = language == null,
|
||||
onClick = { language = null },
|
||||
label = { Text("Toutes langues") },
|
||||
)
|
||||
catalog.languages.forEach { option ->
|
||||
FilterChip(
|
||||
selected = language == option.code,
|
||||
onClick = { language = option.code },
|
||||
label = { Text(option.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
|
||||
else -> LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(results, key = { it.id }) { item ->
|
||||
CatalogRow(
|
||||
item = item,
|
||||
selected = item.id in selection,
|
||||
onToggle = {
|
||||
selection = if (item.id in selection) selection - item.id
|
||||
else selection + item.id
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.size(88.dp)) }
|
||||
when {
|
||||
searching -> 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(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
|
||||
private fun CatalogRow(
|
||||
item: CatalogItem,
|
||||
selected: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
private fun CatalogRow(item: CatalogItem, onOpen: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.surface,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle),
|
||||
.clickable(onClick = onOpen),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -225,7 +199,7 @@ private fun CatalogRow(
|
||||
Artwork(
|
||||
uri = item.artworkUri,
|
||||
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),
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
@@ -249,7 +223,11 @@ private fun CatalogRow(
|
||||
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
|
||||
|
||||
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.Box
|
||||
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.offline.OfflineState
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MediaRow(
|
||||
entry: MediaEntry,
|
||||
@@ -45,6 +47,8 @@ fun MediaRow(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Non nul en mode sélection : la ligne porte alors une case à cocher au lieu de son action. */
|
||||
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(
|
||||
targetValue = when {
|
||||
@@ -60,7 +64,7 @@ fun MediaRow(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -59,6 +59,7 @@ fun LibraryScreen(
|
||||
container: AppContainer,
|
||||
currentMediaId: String?,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
onAddToPlaylist: (MediaEntry) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
@@ -169,6 +170,7 @@ fun LibraryScreen(
|
||||
onClick = { onPlay(visible, visible.indexOf(entry)) },
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
onLongClick = { onAddToPlaylist(entry) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ fun DownloadsScreen(
|
||||
container: AppContainer,
|
||||
currentMediaId: String?,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
onAddToPlaylist: (MediaEntry) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
@@ -98,6 +99,7 @@ fun DownloadsScreen(
|
||||
onClick = { onPlay(cached, cached.indexOf(entry)) },
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
onLongClick = { onAddToPlaylist(entry) },
|
||||
)
|
||||
if (state?.isActive == true) {
|
||||
LinearProgressIndicator(
|
||||
|
||||
@@ -2,6 +2,7 @@ package fr.tuxgyver.cachette.ui.player
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.rounded.CheckCircle
|
||||
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.Pause
|
||||
import androidx.compose.material.icons.rounded.PlaylistAdd
|
||||
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.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -71,6 +74,8 @@ fun NowPlayingScreen(
|
||||
onSkipBy: (Long) -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
onAddToPlaylist: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onRemoveOffline: () -> Unit,
|
||||
) {
|
||||
@@ -112,6 +117,12 @@ fun NowPlayingScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -190,9 +201,12 @@ fun NowPlayingScreen(
|
||||
IconButton(onClick = onPrevious, enabled = state.hasPrevious) {
|
||||
Icon(Icons.Rounded.SkipPrevious, contentDescription = "Précédent")
|
||||
}
|
||||
IconButton(onClick = { onSkipBy(-10_000) }) {
|
||||
Icon(Icons.Rounded.Replay10, contentDescription = "Reculer de 10 secondes")
|
||||
}
|
||||
SkipButton(
|
||||
icon = Icons.Rounded.FastRewind,
|
||||
label = "15 s",
|
||||
description = "Reculer de 15 secondes",
|
||||
onClick = { onSkipBy(-15_000) },
|
||||
)
|
||||
FilledIconButton(
|
||||
onClick = onTogglePlay,
|
||||
shape = CircleShape,
|
||||
@@ -213,9 +227,12 @@ fun NowPlayingScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onSkipBy(30_000) }) {
|
||||
Icon(Icons.Rounded.Forward30, contentDescription = "Avancer de 30 secondes")
|
||||
}
|
||||
SkipButton(
|
||||
icon = Icons.Rounded.FastForward,
|
||||
label = "15 s",
|
||||
description = "Avancer de 15 secondes",
|
||||
onClick = { onSkipBy(15_000) },
|
||||
)
|
||||
IconButton(onClick = onNext, enabled = state.hasNext) {
|
||||
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
|
||||
private fun OfflineToggle(
|
||||
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") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||
import androidx.compose.material.icons.rounded.Public
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
@@ -133,7 +134,11 @@ fun SourcesScreen(
|
||||
|
||||
items(CatalogId.entries) { catalog ->
|
||||
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,
|
||||
status = catalog.license,
|
||||
description = catalog.description,
|
||||
|
||||
Reference in new issue
Block a user