Choix des pistes, écoute en ligne, playlists et source Wikimedia (1.2.0)

Un album ou un livre se téléchargeait en aveugle : l'application retenait une
piste arbitraire sans jamais dire laquelle ni ce qu'elle pesait. La fiche d'un
résultat liste désormais toutes ses pistes avec durée et taille, le total est
annoncé, et le bouton chiffre ce qu'on s'apprête à emporter. Chaque piste
s'essaie d'abord en streaming, sans rien mettre en cache.

Les playlists manquaient pour organiser ce qu'on garde : listes nommées,
ajout par appui long, lecture enchaînée. Les contrôles se complètent d'un
arrêt et de sauts de quinze secondes.

Le filtre de langue de LibriVox ne pouvait pas marcher côté serveur : l'API
accepte le paramètre puis l'ignore, « language=French » seul renvoyant des
livres anglais. Le tri se fait sur le champ de chaque livre. Les délais réseau
suivent la latence réellement mesurée des services, LibriVox répondant en
quatorze à seize secondes quelle que soit la taille demandée.

Wikimedia Commons rejoint les catalogues libres. Openverse et ccMixter ont été
essayés puis écartés : le premier refuse l'accès anonyme, le second exige un
en-tête Referer que Media3 ne sait pas poser par téléchargement.
This commit is contained in:
tuxgyver committed 2026-09-13 09:44:56 +02:00
1 parent c1912c2056
commit 2dabfdbc84
24 files changed
+1311 -177

No files matched your search

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