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,