From 2dabfdbc8476619b68bc361cd2b5b92987b2507b Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 13 Sep 2026 09:44:56 +0200 Subject: [PATCH] =?UTF-8?q?Choix=20des=20pistes,=20=C3=A9coute=20en=20lign?= =?UTF-8?q?e,=20playlists=20et=20source=20Wikimedia=20(1.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 31 +++ README.md | 21 +- VERSION | 2 +- .../java/fr/tuxgyver/cachette/CachetteApp.kt | 11 + .../cachette/library/LibraryRepository.kt | 13 +- .../cachette/library/PlaylistStore.kt | 91 +++++++ .../java/fr/tuxgyver/cachette/model/Media.kt | 2 + .../fr/tuxgyver/cachette/model/Playlist.kt | 17 ++ .../cachette/sources/ArchiveOrgCatalog.kt | 70 +++-- .../fr/tuxgyver/cachette/sources/Catalog.kt | 36 ++- .../java/fr/tuxgyver/cachette/sources/Http.kt | 10 +- .../cachette/sources/LibriVoxCatalog.kt | 26 +- .../cachette/sources/WikimediaCatalog.kt | 96 +++++++ .../fr/tuxgyver/cachette/ui/CachetteRoot.kt | 59 ++++ .../cachette/ui/browse/CatalogBrowseScreen.kt | 210 +++++++-------- .../cachette/ui/browse/CatalogItemScreen.kt | 255 ++++++++++++++++++ .../cachette/ui/components/MediaRow.kt | 8 +- .../cachette/ui/library/LibraryScreen.kt | 2 + .../cachette/ui/offline/DownloadsScreen.kt | 2 + .../cachette/ui/player/NowPlayingScreen.kt | 54 +++- .../ui/playlists/AddToPlaylistSheet.kt | 138 ++++++++++ .../ui/playlists/PlaylistDetailScreen.kt | 136 ++++++++++ .../cachette/ui/playlists/PlaylistsScreen.kt | 191 +++++++++++++ .../cachette/ui/sources/SourcesScreen.kt | 7 +- 24 files changed, 1311 insertions(+), 177 deletions(-) create mode 100644 app/src/main/java/fr/tuxgyver/cachette/library/PlaylistStore.kt create mode 100644 app/src/main/java/fr/tuxgyver/cachette/model/Playlist.kt create mode 100644 app/src/main/java/fr/tuxgyver/cachette/sources/WikimediaCatalog.kt create mode 100644 app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogItemScreen.kt create mode 100644 app/src/main/java/fr/tuxgyver/cachette/ui/playlists/AddToPlaylistSheet.kt create mode 100644 app/src/main/java/fr/tuxgyver/cachette/ui/playlists/PlaylistDetailScreen.kt create mode 100644 app/src/main/java/fr/tuxgyver/cachette/ui/playlists/PlaylistsScreen.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cab916..833c165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/README.md b/README.md index b316dc5..7265ab1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-1.1.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-1.2.0-6B4EFF)](VERSION) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les @@ -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 `` | | 🏛️ 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 ``` --- diff --git a/VERSION b/VERSION index 9084fa2..26aaba0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt b/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt index 435a07f..7687337 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt @@ -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 = 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) diff --git a/app/src/main/java/fr/tuxgyver/cachette/library/LibraryRepository.kt b/app/src/main/java/fr/tuxgyver/cachette/library/LibraryRepository.kt index 6bc2a25..eddbddb 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/library/LibraryRepository.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/library/LibraryRepository.kt @@ -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 = emptyList() + + fun preview(entries: List) { + 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) } diff --git a/app/src/main/java/fr/tuxgyver/cachette/library/PlaylistStore.kt b/app/src/main/java/fr/tuxgyver/cachette/library/PlaylistStore.kt new file mode 100644 index 0000000..8898ad8 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/library/PlaylistStore.kt @@ -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>(emptyList()) + val playlists: StateFlow> = _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) { + 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) -> List): List = + mutex.withLock { + val updated = transform(_playlists.value) + _playlists.value = updated + write(updated) + updated + } + + private suspend fun read(): List = withContext(Dispatchers.IO) { + if (!file.exists()) return@withContext emptyList() + runCatching { json.decodeFromString>(file.readText()) }.getOrElse { emptyList() } + } + + private suspend fun write(playlists: List) = withContext(Dispatchers.IO) { + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(json.encodeToString(playlists)) + tmp.renameTo(file) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/model/Media.kt b/app/src/main/java/fr/tuxgyver/cachette/model/Media.kt index da506ee..b7e1ccc 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/model/Media.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/model/Media.kt @@ -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 diff --git a/app/src/main/java/fr/tuxgyver/cachette/model/Playlist.kt b/app/src/main/java/fr/tuxgyver/cachette/model/Playlist.kt new file mode 100644 index 0000000..7046b88 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/model/Playlist.kt @@ -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 = emptyList(), + val createdAt: Long = 0L, +) diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt index 327a596..ba5af08 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt @@ -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> = withContext(Dispatchers.IO) { + override suspend fun search(query: String, language: String?): Result> = + 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> = withContext(Dispatchers.IO) { + override suspend fun tracks(item: CatalogItem): Result> = 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(), + ) + } } } diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt index 75fe7be..1e360bb 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt @@ -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> + /** Vide si le catalogue ne distingue pas les langues (la musique instrumentale, typiquement). */ + val languages: List get() = emptyList() + + suspend fun search(query: String, language: String? = null): Result> /** - * 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> + suspend fun tracks(item: CatalogItem): Result> } 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"), +) diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/Http.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/Http.kt index 4f0819a..3cccc57 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/Http.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/Http.kt @@ -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)") } diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt index 5e04ca3..ed785a3 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt @@ -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> = withContext(Dispatchers.IO) { + override suspend fun search(query: String, language: String?): Result> = + 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> = - rss.import(item.id).map { it.episodes } + override suspend fun tracks(item: CatalogItem): Result> = + rss.import(item.id).map { feed -> feed.episodes } - private fun books(criterion: String): List { - 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 { + 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, diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/WikimediaCatalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/WikimediaCatalog.kt new file mode 100644 index 0000000..9f1853c --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/WikimediaCatalog.kt @@ -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> = + 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> = 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") + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt index e304d5b..341c6ae 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt @@ -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(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 } } diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt index d59dbee..3daef04 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt @@ -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(null) } var results by remember { mutableStateOf>(emptyList()) } - var selection by remember { mutableStateOf(emptySet()) } var searching by remember { mutableStateOf(false) } - var adding by remember { mutableStateOf(false) } var error by remember { mutableStateOf(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, + ) } } } diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogItemScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogItemScreen.kt new file mode 100644 index 0000000..9bf3c6b --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogItemScreen.kt @@ -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>(emptyList()) } + var selection by remember { mutableStateOf(emptySet()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(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() }) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/components/MediaRow.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/components/MediaRow.kt index 8b40ddf..46daf77 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/components/MediaRow.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/components/MediaRow.kt @@ -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, diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/library/LibraryScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/library/LibraryScreen.kt index 39f1ca4..67fbc27 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/library/LibraryScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/library/LibraryScreen.kt @@ -59,6 +59,7 @@ fun LibraryScreen( container: AppContainer, currentMediaId: String?, onPlay: (List, 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) }, ) } } diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt index 66099e8..882f84c 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt @@ -45,6 +45,7 @@ fun DownloadsScreen( container: AppContainer, currentMediaId: String?, onPlay: (List, 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( diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/player/NowPlayingScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/player/NowPlayingScreen.kt index f33e18b..523d23f 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/player/NowPlayingScreen.kt @@ -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?, diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/AddToPlaylistSheet.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/AddToPlaylistSheet.kt new file mode 100644 index 0000000..c3f531f --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/AddToPlaylistSheet.kt @@ -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") + } + } + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/PlaylistDetailScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/PlaylistDetailScreen.kt new file mode 100644 index 0000000..f54c51e --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/PlaylistDetailScreen.kt @@ -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, 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", + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/PlaylistsScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/PlaylistsScreen.kt new file mode 100644 index 0000000..30e5475 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/playlists/PlaylistsScreen.kt @@ -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(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") } }, + ) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt index 3f18656..285dcfb 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt @@ -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,