Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dabfdbc84 | ||
|
|
c1912c2056 |
No files matched your search
@@ -11,3 +11,4 @@ keystore.properties
|
||||
*.jks
|
||||
.gitea-token
|
||||
Cachette-*.apk
|
||||
update-token.properties
|
||||
@@ -5,6 +5,59 @@ Toutes les évolutions notables de Cachette sont documentées ici.
|
||||
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||
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é
|
||||
|
||||
- Mise à jour intégrée : Cachette surveille les releases du dépôt, télécharge l'APK en Wi-Fi
|
||||
et propose l'installation. Vérification quotidienne en tâche de fond, et à la demande depuis
|
||||
l'écran Sources, qui affiche aussi la version installée.
|
||||
- La taille approximative de chaque résultat Internet Archive est affichée avant sélection : un
|
||||
élément de 500 Mo ne se laisse plus confondre avec une chanson.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- Recherche LibriVox qui ne renvoyait rien. L'API cherche les titres en correspondance exacte
|
||||
sauf si la requête est préfixée par `^`, et les auteurs sur le seul nom de famille : « Jules
|
||||
Verne » n'atteignait donc ni l'un ni l'autre. Les trois critères sont désormais interrogés en
|
||||
parallèle et dédoublonnés, et un « aucun résultat » (HTTP 404) n'est plus traité comme une panne.
|
||||
- Téléchargements Internet Archive qui échouaient ou n'aboutissaient pas. Trois causes distinctes :
|
||||
les éléments en accès restreint répondaient 401 et sont maintenant écartés dès la recherche ;
|
||||
seuls les fichiers `.mp3` et `.mp4` étaient acceptés, ce qui excluait des éléments ne proposant
|
||||
que du `.m4a` ou de l'`.ogg` ; et le fichier retenu était le premier trouvé, souvent l'original
|
||||
non compressé — c'est désormais le plus petit dérivé, soit 9 Mo au lieu de 30 sur un cas mesuré.
|
||||
|
||||
## [1.0.0] — 2026-09-11
|
||||
|
||||
### Ajouté
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/jetpack/compose)
|
||||
[](https://developer.android.com/media/media3)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
[](#-comment-ça-marche)
|
||||
|
||||
Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
|
||||
@@ -30,13 +30,19 @@ sans la moindre connexion.
|
||||
| 🎵 **Audio et vidéo** | Un seul lecteur, une seule file d'attente, la vidéo bascule sur une surface dédiée |
|
||||
| 📥 **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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +54,16 @@ sans la moindre connexion.
|
||||
| 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo |
|
||||
| 📻 Podcast RSS | ✅ | Flux standard avec `<enclosure>` |
|
||||
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
|
||||
| 📚 LibriVox | ✅ | Livres audio du domaine public |
|
||||
| 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue |
|
||||
| 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres |
|
||||
|
||||
### 🔎 Catalogues évalués puis écartés
|
||||
|
||||
| Candidat | Pourquoi non |
|
||||
|---|---|
|
||||
| **Openverse** | A cessé de servir l'accès anonyme : **401 sur huit requêtes sur huit**, et jusqu'à 21 s de latence quand il répondait encore. Exige désormais une inscription. |
|
||||
| **ccMixter** | La recherche fonctionne, mais les fichiers ne sont servis qu'avec un en-tête `Referer` — que Media3 ne permet pas de poser par téléchargement. |
|
||||
| **Radio Browser** | Flux radio continus : rien à mettre en cache. |
|
||||
|
||||
### ⚖️ Ce que Cachette ne fait pas — et pourquoi
|
||||
|
||||
@@ -162,6 +177,7 @@ Gradle, le tag git et les notes de release suivront seuls.
|
||||
| `INTERNET` | Télécharger ce que vous avez choisi de garder |
|
||||
| `POST_NOTIFICATIONS` | Commandes de lecture et progression des téléchargements |
|
||||
| `FOREGROUND_SERVICE_*` | Continuer la lecture et les téléchargements écran éteint |
|
||||
| `REQUEST_INSTALL_PACKAGES` | Proposer l'installation d'une mise à jour téléchargée |
|
||||
|
||||
Aucune donnée ne quitte l'appareil : pas de compte, pas de télémétrie, pas de sauvegarde cloud
|
||||
(`dataExtractionRules` exclut explicitement le contenu mis en cache). Les seules connexions
|
||||
@@ -172,6 +188,37 @@ sortantes sont celles des URL que vous ajoutez et des catalogues que vous interr
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Mises à jour
|
||||
|
||||
Cachette étant distribuée hors magasin, elle surveille elle-même les releases du dépôt.
|
||||
|
||||
```
|
||||
Release publiée ──► vérification quotidienne (WorkManager, Wi-Fi)
|
||||
│
|
||||
├─ version plus récente ? ──► téléchargement de l'APK
|
||||
│ │
|
||||
└─ sinon : rien ▼
|
||||
notification « prête à installer »
|
||||
│
|
||||
installation = geste utilisateur
|
||||
```
|
||||
|
||||
L'installation reste un geste explicite : Android l'impose à toute application hors magasin.
|
||||
L'écran **Sources** porte la même vérification à la demande, et affiche la version installée.
|
||||
|
||||
Le dépôt étant privé, la lecture des releases demande un jeton. Il est lu depuis
|
||||
`update-token.properties` (ignoré par git) et injecté dans `BuildConfig` à la compilation :
|
||||
|
||||
```properties
|
||||
giteaUpdateToken=…
|
||||
```
|
||||
|
||||
> ⚠️ Un jeton embarqué dans un APK est extractible. Celui-ci est volontairement limité à la portée
|
||||
> `read:repository` et révocable depuis Gitea. Il donne un accès en lecture aux dépôts du compte —
|
||||
> ne pas lui accorder davantage.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Structure
|
||||
|
||||
```
|
||||
@@ -183,7 +230,9 @@ app/src/main/java/fr/tuxgyver/cachette/
|
||||
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
|
||||
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
||||
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
|
||||
└── ui/ Compose — thème, bibliothèque, hors ligne, sources, parcours, lecteur
|
||||
├── update/ Vérification des releases, téléchargement, notification
|
||||
└── ui/ Compose — thème, bibliothèque, hors ligne, playlists, sources,
|
||||
parcours des catalogues, choix des pistes, lecteur
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -22,6 +22,18 @@ android {
|
||||
val (major, minor, patch) = appVersion.split(".").map(String::toInt)
|
||||
versionName = appVersion
|
||||
versionCode = major * 10000 + minor * 100 + patch
|
||||
|
||||
// Le dépôt de publication est privé : la vérification des mises à jour a besoin d'un
|
||||
// jeton en lecture seule, lu depuis un fichier local ignoré par git, jamais codé en dur.
|
||||
val updateTokenFile = rootProject.file("update-token.properties")
|
||||
val updateToken = if (updateTokenFile.exists()) {
|
||||
Properties().apply { updateTokenFile.inputStream().use { load(it) } }
|
||||
.getProperty("giteaUpdateToken", "")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
buildConfigField("String", "GITEA_UPDATE_TOKEN", "\"$updateToken\"")
|
||||
buildConfigField("String", "UPDATE_API", "\"https://git.tuxtech.fr/api/v1/repos/tuxgyver/Cachette\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -55,6 +67,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
@@ -58,5 +59,15 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -5,15 +5,20 @@ 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
|
||||
import fr.tuxgyver.cachette.update.UpdateCheckWorker
|
||||
import fr.tuxgyver.cachette.update.UpdateRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -33,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()
|
||||
@@ -40,9 +46,18 @@ class AppContainer(context: Context) {
|
||||
val catalogs: Map<CatalogId, Catalog> = listOf(
|
||||
ArchiveOrgCatalog(),
|
||||
LibriVoxCatalog(rssImporter),
|
||||
WikimediaCatalog(),
|
||||
).associateBy { it.id }
|
||||
|
||||
/**
|
||||
* Résultat de catalogue en cours de consultation.
|
||||
* Un identifiant de route ne suffirait pas à le reconstituer sans un appel réseau de plus.
|
||||
*/
|
||||
var openedCatalogItem: CatalogItem? = null
|
||||
|
||||
val player = PlayerController(context, scope) { id -> library.entry(id) }
|
||||
|
||||
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
class CachetteApp : Application() {
|
||||
@@ -53,5 +68,6 @@ class CachetteApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
UpdateCheckWorker.schedule(this)
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,18 @@ class LibraryRepository(
|
||||
|
||||
fun hasLocalPermission(): Boolean = scanner.hasPermission()
|
||||
|
||||
fun entry(id: String): MediaEntry? = entries.value.firstOrNull { it.id == id }
|
||||
/**
|
||||
* Titres écoutés en aperçu, pas encore ajoutés à la bibliothèque.
|
||||
* Le service de lecture résout les URI par identifiant : sans eux, un aperçu ne démarrerait pas.
|
||||
*/
|
||||
private var previewing: List<MediaEntry> = emptyList()
|
||||
|
||||
fun preview(entries: List<MediaEntry>) {
|
||||
previewing = entries
|
||||
}
|
||||
|
||||
fun entry(id: String): MediaEntry? =
|
||||
entries.value.firstOrNull { it.id == id } ?: previewing.firstOrNull { it.id == id }
|
||||
|
||||
suspend fun forget(id: String) = imported.remove(id)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package fr.tuxgyver.cachette.library
|
||||
|
||||
import android.content.Context
|
||||
import fr.tuxgyver.cachette.model.Playlist
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/** Persistance des playlists, sur le même principe que les médias importés. */
|
||||
class PlaylistStore(context: Context, scope: CoroutineScope) {
|
||||
|
||||
private val file = File(context.filesDir, "playlists.json")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _playlists = MutableStateFlow<List<Playlist>>(emptyList())
|
||||
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch { _playlists.value = read() }
|
||||
}
|
||||
|
||||
suspend fun create(name: String): Playlist = mutate { current ->
|
||||
val playlist = Playlist(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name.trim().ifBlank { "Sans titre" },
|
||||
createdAt = System.currentTimeMillis(),
|
||||
)
|
||||
current + playlist
|
||||
}.last()
|
||||
|
||||
suspend fun delete(id: String) {
|
||||
mutate { current -> current.filterNot { it.id == id } }
|
||||
}
|
||||
|
||||
suspend fun rename(id: String, name: String) {
|
||||
mutate { current ->
|
||||
current.map { if (it.id == id) it.copy(name = name.trim().ifBlank { it.name }) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Ajoute sans doublon : une piste déjà présente n'est pas ajoutée une seconde fois. */
|
||||
suspend fun add(playlistId: String, entryIds: List<String>) {
|
||||
mutate { current ->
|
||||
current.map { playlist ->
|
||||
if (playlist.id != playlistId) playlist
|
||||
else playlist.copy(entryIds = (playlist.entryIds + entryIds).distinct())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun remove(playlistId: String, entryId: String) {
|
||||
mutate { current ->
|
||||
current.map { playlist ->
|
||||
if (playlist.id != playlistId) playlist
|
||||
else playlist.copy(entryIds = playlist.entryIds - entryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playlist(id: String): Playlist? = _playlists.value.firstOrNull { it.id == id }
|
||||
|
||||
private suspend fun mutate(transform: (List<Playlist>) -> List<Playlist>): List<Playlist> =
|
||||
mutex.withLock {
|
||||
val updated = transform(_playlists.value)
|
||||
_playlists.value = updated
|
||||
write(updated)
|
||||
updated
|
||||
}
|
||||
|
||||
private suspend fun read(): List<Playlist> = withContext(Dispatchers.IO) {
|
||||
if (!file.exists()) return@withContext emptyList()
|
||||
runCatching { json.decodeFromString<List<Playlist>>(file.readText()) }.getOrElse { emptyList() }
|
||||
}
|
||||
|
||||
private suspend fun write(playlists: List<Playlist>) = withContext(Dispatchers.IO) {
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(json.encodeToString(playlists))
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ data class MediaEntry(
|
||||
val artworkUri: String? = null,
|
||||
val durationMs: Long = 0L,
|
||||
val addedAt: Long = 0L,
|
||||
/** Taille du fichier quand la source l'annonce, pour choisir avant de télécharger. */
|
||||
val sizeBytes: Long? = null,
|
||||
) {
|
||||
/** Un média local est déjà disponible hors connexion : il n'a rien à télécharger. */
|
||||
val isRemote: Boolean get() = source != SourceKind.LOCAL
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package fr.tuxgyver.cachette.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Une playlist ne contient que des identifiants.
|
||||
*
|
||||
* Les métadonnées appartiennent à la bibliothèque : les dupliquer ici les laisserait diverger
|
||||
* dès qu'un média est renommé ou retiré.
|
||||
*/
|
||||
@Serializable
|
||||
data class Playlist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val entryIds: List<String> = emptyList(),
|
||||
val createdAt: Long = 0L,
|
||||
)
|
||||
@@ -23,13 +23,23 @@ class ArchiveOrgCatalog : Catalog {
|
||||
|
||||
override val id = CatalogId.ARCHIVE
|
||||
|
||||
override val languages = COMMON_LANGUAGES
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
||||
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val q = URLEncoder.encode("($query) AND mediatype:(audio OR movies)", "UTF-8")
|
||||
// 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)$languageClause" +
|
||||
" AND NOT access-restricted-item:true",
|
||||
"UTF-8",
|
||||
)
|
||||
val url = "https://archive.org/advancedsearch.php?q=$q" +
|
||||
"&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype" +
|
||||
"&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype&fl%5B%5D=item_size" +
|
||||
"&rows=30&page=1&output=json"
|
||||
|
||||
val body = fetch(url)
|
||||
@@ -46,43 +56,68 @@ class ArchiveOrgCatalog : Catalog {
|
||||
subtitle = doc.text("creator") ?: "Internet Archive",
|
||||
artworkUri = "https://archive.org/services/img/$identifier",
|
||||
hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio",
|
||||
sizeBytes = doc.text("item_size")?.toLongOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
|
||||
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
|
||||
val files = metadata["files"] as? JsonArray ?: JsonArray(emptyList())
|
||||
|
||||
if (metadata["metadata"]?.jsonObject?.text("access-restricted-item") == "true") {
|
||||
throw IllegalStateException("« ${item.title} » est en accès restreint sur archive.org")
|
||||
}
|
||||
|
||||
val wantsVideo = item.hint == "Vidéo"
|
||||
val extensions = if (wantsVideo) VIDEO_EXTENSIONS else AUDIO_EXTENSIONS
|
||||
val files = (metadata["files"] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject }
|
||||
|
||||
val playable = files.mapNotNull { it as? JsonObject }
|
||||
.firstOrNull { file ->
|
||||
val name = file.text("name").orEmpty().lowercase()
|
||||
if (wantsVideo) name.endsWith(".mp4") else name.endsWith(".mp3")
|
||||
}
|
||||
?: throw IllegalStateException("Aucun fichier lisible pour « ${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}/${URLEncoder.encode(name, "UTF-8").replace("+", "%20")}",
|
||||
artworkUri = item.artworkUri,
|
||||
durationMs = seconds * 1000,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
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} »"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode le nom de fichier sans toucher aux séparateurs de chemin. */
|
||||
private fun encodePath(name: String): String =
|
||||
name.split('/').joinToString("/") {
|
||||
URLEncoder.encode(it, "UTF-8").replace("+", "%20")
|
||||
}
|
||||
|
||||
private fun fetch(url: String): String {
|
||||
val connection = openConnection(url)
|
||||
return try {
|
||||
@@ -94,6 +129,13 @@ class ArchiveOrgCatalog : Catalog {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val AUDIO_EXTENSIONS = listOf(".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav")
|
||||
val VIDEO_EXTENSIONS = listOf(".mp4", ".m4v", ".webm", ".ogv")
|
||||
|
||||
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** archive.org renvoie tantôt une chaîne, tantôt un tableau : on prend la première valeur utile. */
|
||||
|
||||
@@ -2,26 +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 de l'ensemble, quand le catalogue l'annonce. */
|
||||
val sizeBytes: Long? = null,
|
||||
)
|
||||
|
||||
/** Langue proposée au filtrage. [code] est la valeur attendue par l'API du catalogue. */
|
||||
data class CatalogLanguage(val code: String, val label: String)
|
||||
|
||||
/** Catalogue public interrogeable sans compte ni clé d'API. */
|
||||
interface Catalog {
|
||||
val id: CatalogId
|
||||
|
||||
suspend fun search(query: String): Result<List<CatalogItem>>
|
||||
/** Vide si le catalogue ne distingue pas les langues (la musique instrumentale, typiquement). */
|
||||
val languages: List<CatalogLanguage> get() = emptyList()
|
||||
|
||||
suspend fun search(query: String, language: String? = null): Result<List<CatalogItem>>
|
||||
|
||||
/**
|
||||
* Transforme un résultat en entrées jouables. Un livre audio en rend plusieurs, un morceau une
|
||||
* seule : la résolution est faite ici, à la sélection, et pas pendant la recherche.
|
||||
* Les pistes d'un résultat, avec leur taille.
|
||||
*
|
||||
* Un album ou un livre en compte plusieurs : c'est à l'utilisateur de choisir lesquelles il
|
||||
* emporte, pas à l'application de décider pour lui.
|
||||
*/
|
||||
suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>>
|
||||
suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>>
|
||||
}
|
||||
|
||||
enum class CatalogId(
|
||||
@@ -42,4 +52,20 @@ enum class CatalogId(
|
||||
license = "Domaine public",
|
||||
suggestion = "Jules Verne",
|
||||
),
|
||||
WIKIMEDIA(
|
||||
title = "Wikimedia Commons",
|
||||
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
|
||||
license = "Domaine public / Creative Commons",
|
||||
suggestion = "Chopin",
|
||||
),
|
||||
}
|
||||
|
||||
/** Langues les plus fournies, communes à LibriVox et archive.org. */
|
||||
val COMMON_LANGUAGES = listOf(
|
||||
CatalogLanguage("French", "Français"),
|
||||
CatalogLanguage("English", "Anglais"),
|
||||
CatalogLanguage("German", "Allemand"),
|
||||
CatalogLanguage("Spanish", "Espagnol"),
|
||||
CatalogLanguage("Italian", "Italien"),
|
||||
CatalogLanguage("Dutch", "Néerlandais"),
|
||||
)
|
||||
@@ -9,11 +9,15 @@ internal fun stableId(prefix: String, value: String): String {
|
||||
return prefix + digest.joinToString("") { "%02x".format(it) }.take(16)
|
||||
}
|
||||
|
||||
internal fun openConnection(url: String, method: String = "GET"): HttpURLConnection =
|
||||
internal fun openConnection(
|
||||
url: String,
|
||||
method: String = "GET",
|
||||
readTimeoutMs: Int = 20_000,
|
||||
): HttpURLConnection =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 20_000
|
||||
setRequestProperty("User-Agent", "Cachette/1.0 (Android)")
|
||||
readTimeout = readTimeoutMs
|
||||
setRequestProperty("User-Agent", "Cachette/1.1 (Android)")
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package fr.tuxgyver.cachette.sources
|
||||
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
@@ -12,48 +15,85 @@ import java.net.URLEncoder
|
||||
/**
|
||||
* Recherche dans LibriVox.
|
||||
*
|
||||
* Chaque livre publie un flux RSS : la résolution réutilise donc l'import de podcast plutôt que
|
||||
* de redécrire un parcours de chapitres.
|
||||
* L'API a deux pièges, tous deux vérifiés contre le service réel :
|
||||
* le champ `title` fait une correspondance **exacte** sauf s'il est préfixé par `^`
|
||||
* (`title=Alice` ne donne rien, `title=^Alice` donne vingt-trois livres), et le champ `author`
|
||||
* ne matche que le **nom de famille** (`author=Jules Verne` ne donne rien, `author=Verne` si).
|
||||
* Un « aucun résultat » se présente enfin sous la forme d'un HTTP 404, pas d'une liste vide.
|
||||
*
|
||||
* D'où trois interrogations combinées et dédoublonnées, plutôt qu'une seule qui échouerait.
|
||||
*
|
||||
* Chaque livre publie un flux RSS : la résolution réutilise l'import de podcast plutôt que de
|
||||
* redécrire un parcours de chapitres.
|
||||
*/
|
||||
class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
||||
|
||||
override val id = CatalogId.LIBRIVOX
|
||||
|
||||
override val languages = COMMON_LANGUAGES
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
||||
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25" +
|
||||
"&title=${URLEncoder.encode(query, "UTF-8")}"
|
||||
// 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() }
|
||||
|
||||
val connection = openConnection(url)
|
||||
val body = try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
val criteria = buildList {
|
||||
add("title=^$term")
|
||||
add("author=$term")
|
||||
// « Jules Verne » ne matche que sur « Verne » : on retente avec le nom de famille.
|
||||
if (words.size > 1) add("author=${URLEncoder.encode(words.last(), "UTF-8")}")
|
||||
}
|
||||
|
||||
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray
|
||||
?: throw IllegalStateException("Aucun livre trouvé")
|
||||
|
||||
books.mapNotNull { element ->
|
||||
val book = element as? JsonObject ?: return@mapNotNull null
|
||||
val feed = book.text("url_rss") ?: return@mapNotNull null
|
||||
CatalogItem(
|
||||
id = feed,
|
||||
title = book.text("title") ?: "Livre audio",
|
||||
subtitle = book.authors(),
|
||||
hint = "Livre audio",
|
||||
)
|
||||
}
|
||||
// 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, limit, language) } }.awaitAll()
|
||||
}.flatten().distinctBy { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
|
||||
rss.import(item.id).map { it.episodes }
|
||||
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> =
|
||||
rss.import(item.id).map { feed -> feed.episodes }
|
||||
|
||||
private fun books(criterion: String, limit: Int, language: String?): List<CatalogItem> {
|
||||
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=$limit&$criterion"
|
||||
// Le service met 14 à 16 secondes à répondre, quelle que soit la taille demandée :
|
||||
// c'est sa latence propre, pas le volume. D'où une patience adaptée.
|
||||
val connection = openConnection(url, readTimeoutMs = 45_000)
|
||||
val body = try {
|
||||
when (connection.responseCode) {
|
||||
// LibriVox répond 404 quand le critère ne donne rien : ce n'est pas une panne.
|
||||
404 -> return emptyList()
|
||||
in 200..299 -> connection.inputStream.bufferedReader().readText()
|
||||
else -> throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
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,
|
||||
title = book.text("title") ?: "Livre audio",
|
||||
subtitle = book.authors(),
|
||||
hint = book.text("totaltime")?.let { "Livre audio · $it" } ?: "Livre audio",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.authors(): String {
|
||||
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package fr.tuxgyver.cachette.sources
|
||||
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Recherche de fichiers audio sur Wikimedia Commons.
|
||||
*
|
||||
* Retenu après avoir écarté deux candidats vérifiés : Openverse refuse désormais l'accès anonyme
|
||||
* (401 sur huit requêtes sur huit) et ccMixter exige un en-tête `Referer` que Media3 ne permet pas
|
||||
* de poser par téléchargement. Commons, lui, sert ses fichiers directement depuis
|
||||
* upload.wikimedia.org, sans clé ni en-tête particulier, et annonce leur taille.
|
||||
*
|
||||
* Chaque résultat est déjà un fichier unique : la liste de pistes n'en contient donc qu'une.
|
||||
*/
|
||||
class WikimediaCatalog : Catalog {
|
||||
|
||||
override val id = CatalogId.WIKIMEDIA
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val search = URLEncoder.encode("filetype:audio ${query.trim()}", "UTF-8")
|
||||
val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json" +
|
||||
"&generator=search&gsrsearch=$search&gsrnamespace=6&gsrlimit=30" +
|
||||
"&prop=imageinfo&iiprop=url%7Csize%7Cmime%7Cextmetadata"
|
||||
|
||||
val connection = openConnection(url, readTimeoutMs = 30_000)
|
||||
val body = try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Commons a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
val pages = json.parseToJsonElement(body).jsonObject["query"]
|
||||
?.jsonObject?.get("pages")?.jsonObject
|
||||
?: return@runCatching emptyList()
|
||||
|
||||
pages.values.mapNotNull { element ->
|
||||
val page = element as? JsonObject ?: return@mapNotNull null
|
||||
val info = page["imageinfo"]?.jsonArray?.firstOrNull()?.jsonObject
|
||||
?: return@mapNotNull null
|
||||
val fileUrl = info.text("url") ?: return@mapNotNull null
|
||||
if (!PLAYABLE.any { fileUrl.substringBefore('?').lowercase().endsWith(it) }) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val licence = info["extmetadata"]?.jsonObject
|
||||
?.get("LicenseShortName")?.jsonObject?.text("value")
|
||||
|
||||
CatalogItem(
|
||||
id = fileUrl,
|
||||
// Les titres Commons sont préfixés « File: » : inutile à l'écran.
|
||||
title = page.text("title")?.removePrefix("File:")?.substringBeforeLast('.')
|
||||
?: "Sans titre",
|
||||
subtitle = "Wikimedia Commons",
|
||||
hint = licence ?: "Domaine public / CC",
|
||||
sizeBytes = info.text("size")?.toLongOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = Result.success(
|
||||
listOf(
|
||||
MediaEntry(
|
||||
id = stableId("wm:", item.id),
|
||||
source = SourceKind.URL,
|
||||
kind = MediaKind.AUDIO,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
uri = item.id,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
sizeBytes = item.sizeBytes,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private companion object {
|
||||
/** Formats que Media3 sait lire parmi ceux que Commons héberge. */
|
||||
val PLAYABLE = listOf(".ogg", ".oga", ".opus", ".flac", ".wav", ".mp3", ".m4a")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudDownload
|
||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||
import androidx.compose.material.icons.rounded.PlaylistPlay
|
||||
import androidx.compose.material.icons.rounded.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
@@ -39,10 +40,14 @@ import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen
|
||||
import fr.tuxgyver.cachette.ui.browse.CatalogItemScreen
|
||||
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen
|
||||
import fr.tuxgyver.cachette.ui.library.LibraryScreen
|
||||
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
|
||||
import fr.tuxgyver.cachette.ui.player.MiniPlayer
|
||||
import fr.tuxgyver.cachette.ui.playlists.AddToPlaylistSheet
|
||||
import fr.tuxgyver.cachette.ui.playlists.PlaylistDetailScreen
|
||||
import fr.tuxgyver.cachette.ui.playlists.PlaylistsScreen
|
||||
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
|
||||
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -50,11 +55,14 @@ import kotlinx.coroutines.launch
|
||||
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
||||
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
|
||||
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
|
||||
PLAYLISTS("playlists", "Playlists", Icons.Rounded.PlaylistPlay),
|
||||
SOURCES("sources", "Sources", Icons.Rounded.Tune),
|
||||
}
|
||||
|
||||
private const val BROWSE_ROUTE = "browse/{source}"
|
||||
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
||||
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
|
||||
private const val PLAYLIST_ROUTE = "playlist/{playlist}"
|
||||
|
||||
@Composable
|
||||
fun CachetteRoot(container: AppContainer) {
|
||||
@@ -63,6 +71,7 @@ fun CachetteRoot(container: AppContainer) {
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var playlistTarget by remember { mutableStateOf<MediaEntry?>(null) }
|
||||
|
||||
val playerState by container.player.state.collectAsStateWithLifecycle()
|
||||
val position by container.player.positionMs.collectAsStateWithLifecycle()
|
||||
@@ -111,6 +120,7 @@ fun CachetteRoot(container: AppContainer) {
|
||||
container = container,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onPlay = play,
|
||||
onAddToPlaylist = { playlistTarget = it },
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
@@ -120,6 +130,26 @@ fun CachetteRoot(container: AppContainer) {
|
||||
container = container,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onPlay = play,
|
||||
onAddToPlaylist = { playlistTarget = it },
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Tab.PLAYLISTS.route) {
|
||||
PlaylistsScreen(
|
||||
container = container,
|
||||
onOpen = { navController.navigate("playlist/$it") },
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(PLAYLIST_ROUTE) { entry ->
|
||||
PlaylistDetailScreen(
|
||||
container = container,
|
||||
playlistId = entry.arguments?.getString("playlist").orEmpty(),
|
||||
currentMediaId = playerState.mediaId,
|
||||
onBack = navController::popBackStack,
|
||||
onPlay = play,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
@@ -152,6 +182,21 @@ fun CachetteRoot(container: AppContainer) {
|
||||
container = container,
|
||||
catalogId = catalog,
|
||||
onBack = navController::popBackStack,
|
||||
onOpenItem = { item ->
|
||||
container.openedCatalogItem = item
|
||||
navController.navigate("catalog/${catalog.name}/item")
|
||||
},
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(CATALOG_ITEM_ROUTE) { entry ->
|
||||
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
|
||||
CatalogItemScreen(
|
||||
container = container,
|
||||
catalogId = catalog,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onBack = navController::popBackStack,
|
||||
onMessage = notify,
|
||||
contentPadding = padding,
|
||||
)
|
||||
@@ -177,12 +222,26 @@ fun CachetteRoot(container: AppContainer) {
|
||||
onSkipBy = container.player::skipBy,
|
||||
onNext = container.player::next,
|
||||
onPrevious = container.player::previous,
|
||||
onStop = {
|
||||
container.player.stop()
|
||||
expanded = false
|
||||
},
|
||||
onAddToPlaylist = { entry?.let { playlistTarget = it } },
|
||||
onDownload = { entry?.let(container.downloads::download) },
|
||||
onRemoveOffline = { playerState.mediaId?.let(container.downloads::remove) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
playlistTarget?.let { target ->
|
||||
AddToPlaylistSheet(
|
||||
container = container,
|
||||
entry = target,
|
||||
onDismiss = { playlistTarget = null },
|
||||
onMessage = notify,
|
||||
)
|
||||
}
|
||||
|
||||
BackHandler(enabled = expanded) { expanded = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package fr.tuxgyver.cachette.ui.browse
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -14,15 +15,17 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -49,13 +52,14 @@ import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
import fr.tuxgyver.cachette.sources.CatalogItem
|
||||
import fr.tuxgyver.cachette.ui.components.Artwork
|
||||
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
|
||||
@@ -63,17 +67,16 @@ fun CatalogBrowseScreen(
|
||||
container: AppContainer,
|
||||
catalogId: CatalogId,
|
||||
onBack: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
onOpenItem: (CatalogItem) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val catalog = container.catalogs.getValue(catalogId)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
|
||||
var language by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
|
||||
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun runSearch() {
|
||||
@@ -81,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" },
|
||||
)
|
||||
@@ -89,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,
|
||||
@@ -224,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),
|
||||
)
|
||||
@@ -237,14 +212,22 @@ private fun CatalogRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = listOf(item.subtitle, item.hint).filter { it.isNotBlank() }.joinToString(" · "),
|
||||
text = listOfNotNull(
|
||||
item.subtitle.takeIf { it.isNotBlank() },
|
||||
item.hint.takeIf { it.isNotBlank() },
|
||||
item.sizeBytes?.let { "~${formatBytes(it)}" },
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Checkbox(checked = selected, onCheckedChange = { onToggle() })
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = "Voir les pistes",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package fr.tuxgyver.cachette.ui.browse
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.GraphicEq
|
||||
import androidx.compose.material.icons.rounded.PlayCircleOutline
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
import fr.tuxgyver.cachette.ui.components.formatBytes
|
||||
import fr.tuxgyver.cachette.ui.components.formatDuration
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Les pistes d'un résultat de catalogue.
|
||||
*
|
||||
* Un album ou un livre en compte souvent des dizaines : chacune s'écoute d'abord en ligne, et
|
||||
* n'est mise en cache que si on la coche. La taille de la sélection est annoncée avant l'envoi.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CatalogItemScreen(
|
||||
container: AppContainer,
|
||||
catalogId: CatalogId,
|
||||
currentMediaId: String?,
|
||||
onBack: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val item = container.openedCatalogItem
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var tracks by remember { mutableStateOf<List<MediaEntry>>(emptyList()) }
|
||||
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(item?.id) {
|
||||
val opened = item ?: run {
|
||||
onBack()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
loading = true
|
||||
container.catalogs.getValue(catalogId).tracks(opened).fold(
|
||||
onSuccess = { tracks = it; error = if (it.isEmpty()) "Aucune piste lisible" else null },
|
||||
onFailure = { error = it.message ?: "Contenu illisible" },
|
||||
)
|
||||
loading = false
|
||||
}
|
||||
|
||||
val selectedSize = remember(selection, tracks) {
|
||||
tracks.filter { it.id in selection }.sumOf { it.sizeBytes ?: 0L }
|
||||
}
|
||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = item?.title ?: "Pistes",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (tracks.isNotEmpty()) {
|
||||
TextButton(onClick = {
|
||||
selection = if (selection.size == tracks.size) emptySet()
|
||||
else tracks.mapTo(mutableSetOf()) { it.id }
|
||||
}) {
|
||||
Text(if (selection.size == tracks.size) "Aucune" else "Toutes")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (tracks.isNotEmpty()) {
|
||||
val total = tracks.sumOf { it.sizeBytes ?: 0L }
|
||||
Text(
|
||||
text = buildString {
|
||||
append("${tracks.size} piste(s)")
|
||||
if (total > 0) append(" · ${formatBytes(total)} au total")
|
||||
append(" · appuyez pour écouter en ligne")
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
error != null -> Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = error!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
else -> LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(tracks, key = { it.id }) { track ->
|
||||
TrackRow(
|
||||
track = track,
|
||||
playing = track.id == currentMediaId,
|
||||
selected = track.id in selection,
|
||||
onPreview = {
|
||||
// Un aperçu ne rejoint pas la bibliothèque : on le confie au
|
||||
// registre transitoire pour que la lecture sache le résoudre.
|
||||
container.library.preview(tracks)
|
||||
container.player.play(tracks, tracks.indexOf(track))
|
||||
},
|
||||
onToggle = {
|
||||
selection = if (track.id in selection) selection - track.id
|
||||
else selection + track.id
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.size(88.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SelectionBar(
|
||||
count = selection.size,
|
||||
actionLabel = if (selectedSize > 0) "Garder ${formatBytes(selectedSize)}" else "Garder",
|
||||
busy = adding,
|
||||
onClear = { selection = emptySet() },
|
||||
onConfirm = {
|
||||
adding = true
|
||||
scope.launch {
|
||||
val chosen = tracks.filter { it.id in selection }
|
||||
val added = container.imported.add(chosen)
|
||||
chosen.forEach(container.downloads::download)
|
||||
adding = false
|
||||
selection = emptySet()
|
||||
onMessage(
|
||||
if (added == 0) "Ces pistes sont déjà dans la bibliothèque"
|
||||
else "$added piste(s) ajoutées, téléchargement lancé"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(contentPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackRow(
|
||||
track: MediaEntry,
|
||||
playing: Boolean,
|
||||
selected: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = when {
|
||||
playing -> MaterialTheme.colorScheme.primaryContainer
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPreview),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
|
||||
contentDescription = if (playing) "En lecture" else "Écouter en ligne",
|
||||
tint = if (playing) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
track.durationMs.takeIf { it > 0 }?.let(::formatDuration),
|
||||
track.sizeBytes?.takeIf { it > 0 }?.let(::formatBytes),
|
||||
).joinToString(" · ").ifBlank { "Taille inconnue" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Checkbox(checked = selected, onCheckedChange = { onToggle() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package fr.tuxgyver.cachette.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.offline.OfflineState
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MediaRow(
|
||||
entry: MediaEntry,
|
||||
@@ -45,6 +47,8 @@ fun MediaRow(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Non nul en mode sélection : la ligne porte alors une case à cocher au lieu de son action. */
|
||||
selected: Boolean? = null,
|
||||
/** Appui long : ajout à une playlist. Absent là où le geste n'a pas de sens. */
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val background by animateColorAsState(
|
||||
targetValue = when {
|
||||
@@ -60,7 +64,7 @@ fun MediaRow(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -59,6 +59,7 @@ fun LibraryScreen(
|
||||
container: AppContainer,
|
||||
currentMediaId: String?,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
onAddToPlaylist: (MediaEntry) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
@@ -169,6 +170,7 @@ fun LibraryScreen(
|
||||
onClick = { onPlay(visible, visible.indexOf(entry)) },
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
onLongClick = { onAddToPlaylist(entry) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ fun DownloadsScreen(
|
||||
container: AppContainer,
|
||||
currentMediaId: String?,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
onAddToPlaylist: (MediaEntry) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
@@ -98,6 +99,7 @@ fun DownloadsScreen(
|
||||
onClick = { onPlay(cached, cached.indexOf(entry)) },
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
onLongClick = { onAddToPlaylist(entry) },
|
||||
)
|
||||
if (state?.isActive == true) {
|
||||
LinearProgressIndicator(
|
||||
|
||||
@@ -2,6 +2,7 @@ package fr.tuxgyver.cachette.ui.player
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -19,11 +20,13 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material.icons.rounded.Forward30
|
||||
import androidx.compose.material.icons.rounded.FastForward
|
||||
import androidx.compose.material.icons.rounded.FastRewind
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PlaylistAdd
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Replay10
|
||||
import androidx.compose.material.icons.rounded.Stop
|
||||
import androidx.compose.material.icons.rounded.SkipNext
|
||||
import androidx.compose.material.icons.rounded.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -71,6 +74,8 @@ fun NowPlayingScreen(
|
||||
onSkipBy: (Long) -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
onAddToPlaylist: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onRemoveOffline: () -> Unit,
|
||||
) {
|
||||
@@ -112,6 +117,12 @@ fun NowPlayingScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onAddToPlaylist) {
|
||||
Icon(Icons.Rounded.PlaylistAdd, contentDescription = "Ajouter à une playlist")
|
||||
}
|
||||
IconButton(onClick = onStop) {
|
||||
Icon(Icons.Rounded.Stop, contentDescription = "Arrêter la lecture")
|
||||
}
|
||||
OfflineToggle(entry, offline, onDownload, onRemoveOffline)
|
||||
}
|
||||
|
||||
@@ -190,9 +201,12 @@ fun NowPlayingScreen(
|
||||
IconButton(onClick = onPrevious, enabled = state.hasPrevious) {
|
||||
Icon(Icons.Rounded.SkipPrevious, contentDescription = "Précédent")
|
||||
}
|
||||
IconButton(onClick = { onSkipBy(-10_000) }) {
|
||||
Icon(Icons.Rounded.Replay10, contentDescription = "Reculer de 10 secondes")
|
||||
}
|
||||
SkipButton(
|
||||
icon = Icons.Rounded.FastRewind,
|
||||
label = "15 s",
|
||||
description = "Reculer de 15 secondes",
|
||||
onClick = { onSkipBy(-15_000) },
|
||||
)
|
||||
FilledIconButton(
|
||||
onClick = onTogglePlay,
|
||||
shape = CircleShape,
|
||||
@@ -213,9 +227,12 @@ fun NowPlayingScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onSkipBy(30_000) }) {
|
||||
Icon(Icons.Rounded.Forward30, contentDescription = "Avancer de 30 secondes")
|
||||
}
|
||||
SkipButton(
|
||||
icon = Icons.Rounded.FastForward,
|
||||
label = "15 s",
|
||||
description = "Avancer de 15 secondes",
|
||||
onClick = { onSkipBy(15_000) },
|
||||
)
|
||||
IconButton(onClick = onNext, enabled = state.hasNext) {
|
||||
Icon(Icons.Rounded.SkipNext, contentDescription = "Suivant")
|
||||
}
|
||||
@@ -226,6 +243,27 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Material ne fournit pas d'icône « 15 » : le pas de saut est donc écrit sous l'icône. */
|
||||
@Composable
|
||||
private fun SkipButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
description: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = description)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineToggle(
|
||||
entry: MediaEntry?,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package fr.tuxgyver.cachette.ui.playlists
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.PlaylistPlay
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Choix de la playlist d'accueil, avec création à la volée si aucune ne convient. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddToPlaylistSheet(
|
||||
container: AppContainer,
|
||||
entry: MediaEntry,
|
||||
onDismiss: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
) {
|
||||
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var newName by remember { mutableStateOf("") }
|
||||
|
||||
fun addTo(playlistId: String, name: String) {
|
||||
scope.launch {
|
||||
container.playlists.add(playlistId, listOf(entry.id))
|
||||
onMessage("« ${entry.title} » ajouté à $name")
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 24.dp)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Text("Ajouter à une playlist", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
if (playlists.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.heightIn(max = 280.dp),
|
||||
) {
|
||||
items(playlists, key = { it.id }) { playlist ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { addTo(playlist.id, playlist.name) }
|
||||
.padding(vertical = 12.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.PlaylistPlay, contentDescription = null)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(playlist.name, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "${playlist.entryIds.size} titre(s)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
placeholder = { Text("Nouvelle playlist") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val created = container.playlists.create(newName)
|
||||
container.playlists.add(created.id, listOf(entry.id))
|
||||
onMessage("« ${entry.title} » ajouté à ${created.name}")
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text("Créer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package fr.tuxgyver.cachette.ui.playlists
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.RemoveCircleOutline
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.ui.components.MediaRow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Le contenu d'une playlist, dans l'ordre d'ajout. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(
|
||||
container: AppContainer,
|
||||
playlistId: String,
|
||||
currentMediaId: String?,
|
||||
onBack: () -> Unit,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
||||
val library by container.library.entries.collectAsStateWithLifecycle()
|
||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val playlist = playlists.firstOrNull { it.id == playlistId }
|
||||
|
||||
// Un identifiant sans média correspondant est ignoré : la bibliothèque reste la référence.
|
||||
val tracks = remember(playlist, library) {
|
||||
playlist?.entryIds?.mapNotNull { id -> library.firstOrNull { it.id == id } }.orEmpty()
|
||||
}
|
||||
|
||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = playlist?.name ?: "Playlist",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (tracks.isNotEmpty()) {
|
||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
Button(onClick = { onPlay(tracks, 0) }) {
|
||||
Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Tout lire")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tracks.isEmpty()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Cette playlist est vide. Appuyez longuement sur un média de la bibliothèque pour l'y ajouter.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(tracks, key = { it.id }) { entry ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MediaRow(
|
||||
entry = entry,
|
||||
offline = offlineStates[entry.id],
|
||||
isCurrent = entry.id == currentMediaId,
|
||||
onClick = { onPlay(tracks, tracks.indexOf(entry)) },
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = {
|
||||
scope.launch { container.playlists.remove(playlistId, entry.id) }
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Rounded.RemoveCircleOutline,
|
||||
contentDescription = "Retirer de la playlist",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package fr.tuxgyver.cachette.ui.playlists
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.PlaylistPlay
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun PlaylistsScreen(
|
||||
container: AppContainer,
|
||||
onOpen: (String) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
var pendingDelete by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(top = 12.dp)) {
|
||||
Text("Playlists", style = MaterialTheme.typography.displaySmall)
|
||||
Text(
|
||||
text = "${playlists.size} liste(s)",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(14.dp))
|
||||
Button(onClick = { creating = true }) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Nouvelle playlist")
|
||||
}
|
||||
Spacer(Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (playlists.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Rounded.PlaylistPlay,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text("Aucune playlist", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(
|
||||
text = "Appuyez longuement sur un média de la bibliothèque pour l'ajouter à une liste.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(playlists, key = { it.id }) { playlist ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 1.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpen(playlist.id) },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.PlaylistPlay,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = playlist.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${playlist.entryIds.size} titre(s)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { pendingDelete = playlist.id }) {
|
||||
Icon(Icons.Rounded.Delete, contentDescription = "Supprimer la playlist")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { creating = false },
|
||||
title = { Text("Nouvelle playlist") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
placeholder = { Text("Nom de la liste") },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = name.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch { container.playlists.create(name) }
|
||||
creating = false
|
||||
},
|
||||
) { Text("Créer") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { creating = false }) { Text("Annuler") } },
|
||||
)
|
||||
}
|
||||
|
||||
pendingDelete?.let { id ->
|
||||
val name = container.playlists.playlist(id)?.name.orEmpty()
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text("Supprimer « $name » ?") },
|
||||
text = { Text("La liste disparaît. Les médias et les fichiers mis en cache restent.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch { container.playlists.delete(id) }
|
||||
pendingDelete = null
|
||||
}) { Text("Supprimer") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { pendingDelete = null }) { Text("Annuler") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||
import androidx.compose.material.icons.rounded.Public
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
@@ -133,7 +134,11 @@ fun SourcesScreen(
|
||||
|
||||
items(CatalogId.entries) { catalog ->
|
||||
SourceCard(
|
||||
icon = if (catalog == CatalogId.LIBRIVOX) Icons.AutoMirrored.Rounded.MenuBook else Icons.Rounded.Public,
|
||||
icon = when (catalog) {
|
||||
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
|
||||
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
|
||||
else -> Icons.Rounded.Public
|
||||
},
|
||||
title = catalog.title,
|
||||
status = catalog.license,
|
||||
description = catalog.description,
|
||||
@@ -149,6 +154,11 @@ fun SourcesScreen(
|
||||
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
UpdateCard(container)
|
||||
}
|
||||
|
||||
unavailableSourceCards(
|
||||
listOf(
|
||||
UnavailableSource(
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package fr.tuxgyver.cachette.ui.sources
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.SystemUpdate
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.BuildConfig
|
||||
import fr.tuxgyver.cachette.update.UpdateInfo
|
||||
import fr.tuxgyver.cachette.update.UpdateNotifier
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
private sealed interface UpdateUiState {
|
||||
data object Idle : UpdateUiState
|
||||
data object Checking : UpdateUiState
|
||||
data object UpToDate : UpdateUiState
|
||||
data class Available(val info: UpdateInfo) : UpdateUiState
|
||||
data class Downloading(val info: UpdateInfo) : UpdateUiState
|
||||
data class Ready(val info: UpdateInfo, val apk: File) : UpdateUiState
|
||||
data class Failed(val message: String) : UpdateUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérification manuelle des mises à jour.
|
||||
*
|
||||
* La vérification automatique tourne une fois par jour en tâche de fond ; cette carte donne le
|
||||
* même résultat à la demande, et rend visible la version installée.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateCard(container: AppContainer) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<UpdateUiState>(UpdateUiState.Idle) }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.SystemUpdate,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Mises à jour", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "Version installée ${BuildConfig.VERSION_NAME}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(10.dp))
|
||||
|
||||
val current = state
|
||||
Text(
|
||||
text = when (current) {
|
||||
UpdateUiState.Idle ->
|
||||
"Cachette regarde chaque jour si une nouvelle version a été publiée, et la télécharge en Wi-Fi."
|
||||
UpdateUiState.Checking -> "Vérification en cours…"
|
||||
UpdateUiState.UpToDate -> "Vous avez déjà la dernière version."
|
||||
is UpdateUiState.Available -> "Version ${current.info.version} disponible."
|
||||
is UpdateUiState.Downloading -> "Téléchargement de la version ${current.info.version}…"
|
||||
is UpdateUiState.Ready -> "Version ${current.info.version} téléchargée, prête à installer."
|
||||
is UpdateUiState.Failed -> current.message
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (current is UpdateUiState.Failed) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(12.dp))
|
||||
|
||||
when (current) {
|
||||
is UpdateUiState.Ready -> Button(
|
||||
onClick = { UpdateNotifier.install(context, current.apk) }
|
||||
) { Text("Installer") }
|
||||
|
||||
is UpdateUiState.Available -> Button(onClick = {
|
||||
state = UpdateUiState.Downloading(current.info)
|
||||
scope.launch {
|
||||
runCatching { container.updates.downloadApk(current.info) }.fold(
|
||||
onSuccess = { state = UpdateUiState.Ready(current.info, it) },
|
||||
onFailure = {
|
||||
state = UpdateUiState.Failed(it.message ?: "Téléchargement impossible")
|
||||
},
|
||||
)
|
||||
}
|
||||
}) { Text("Télécharger") }
|
||||
|
||||
UpdateUiState.Checking, is UpdateUiState.Downloading ->
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||
|
||||
else -> Button(onClick = {
|
||||
state = UpdateUiState.Checking
|
||||
scope.launch {
|
||||
runCatching { container.updates.checkForUpdate() }.fold(
|
||||
onSuccess = { info ->
|
||||
state = if (info == null) UpdateUiState.UpToDate
|
||||
else UpdateUiState.Available(info)
|
||||
},
|
||||
onFailure = {
|
||||
state = UpdateUiState.Failed(it.message ?: "Vérification impossible")
|
||||
},
|
||||
)
|
||||
}
|
||||
}) { Text("Rechercher une mise à jour") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package fr.tuxgyver.cachette.update
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import fr.tuxgyver.cachette.CachetteApp
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Vérifie chaque jour la dernière release du dépôt, télécharge l'APK si une version plus récente
|
||||
* existe, puis notifie. L'installation reste un geste de l'utilisateur, imposé par Android.
|
||||
*/
|
||||
class UpdateCheckWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val repository = (applicationContext as CachetteApp).container.updates
|
||||
val update = runCatching { repository.checkForUpdate() }.getOrNull()
|
||||
?: return Result.success()
|
||||
val apk = runCatching { repository.downloadApk(update) }.getOrNull()
|
||||
?: return Result.retry()
|
||||
UpdateNotifier.notifyReadyToInstall(applicationContext, update.version, apk)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "cachette-update-check"
|
||||
|
||||
/** Une vérification par jour, sur réseau non facturé à l'usage. */
|
||||
fun schedule(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package fr.tuxgyver.cachette.update
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import fr.tuxgyver.cachette.R
|
||||
import java.io.File
|
||||
|
||||
/** Prévient que la mise à jour est téléchargée et ouvre l'installateur système d'un appui. */
|
||||
object UpdateNotifier {
|
||||
|
||||
private const val CHANNEL_ID = "cachette_updates"
|
||||
private const val NOTIFICATION_ID = 4301
|
||||
|
||||
fun notifyReadyToInstall(context: Context, version: String, apk: File) {
|
||||
createChannel(context)
|
||||
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
|
||||
val install = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
install,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_offline)
|
||||
.setContentTitle("Cachette $version est prête")
|
||||
.setContentText("Appuyez pour installer la mise à jour.")
|
||||
.setContentIntent(pending)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.build()
|
||||
|
||||
// Sans la permission de notifier, le téléchargement reste valable : l'écran Sources
|
||||
// propose la même installation. On s'abstient donc plutôt que de planter.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
/** Ouvre l'installateur système pour un APK déjà téléchargé. */
|
||||
fun install(context: Context, apk: File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Mises à jour",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { description = "Disponibilité d'une nouvelle version de Cachette" }
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package fr.tuxgyver.cachette.update
|
||||
|
||||
import android.content.Context
|
||||
import fr.tuxgyver.cachette.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class UpdateInfo(
|
||||
val version: String,
|
||||
val notes: String,
|
||||
val downloadUrl: String,
|
||||
val apkFileName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vérifie la dernière release publiée sur le dépôt et en télécharge l'APK.
|
||||
*
|
||||
* Le dépôt étant privé, chaque appel est authentifié par un jeton en lecture seule injecté à la
|
||||
* compilation. L'installation elle-même reste un geste de l'utilisateur : Android l'impose à
|
||||
* toute application distribuée hors magasin.
|
||||
*/
|
||||
class UpdateRepository(
|
||||
private val context: Context,
|
||||
private val currentVersion: String,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
if (BuildConfig.GITEA_UPDATE_TOKEN.isBlank()) return@withContext null
|
||||
|
||||
val body = runCatching { fetch("${BuildConfig.UPDATE_API}/releases/latest") }.getOrNull()
|
||||
?: return@withContext null
|
||||
|
||||
val release = json.parseToJsonElement(body).jsonObject
|
||||
val remote = release["tag_name"]?.jsonPrimitive?.content?.removePrefix("v")
|
||||
?: return@withContext null
|
||||
if (!isNewer(remote, currentVersion)) return@withContext null
|
||||
|
||||
val apk = (release["assets"] as? JsonArray)
|
||||
?.mapNotNull { it as? JsonObject }
|
||||
?.firstOrNull { it["name"]?.jsonPrimitive?.content?.endsWith(".apk") == true }
|
||||
?: return@withContext null
|
||||
|
||||
UpdateInfo(
|
||||
version = remote,
|
||||
notes = release["body"]?.jsonPrimitive?.content.orEmpty(),
|
||||
downloadUrl = apk["browser_download_url"]?.jsonPrimitive?.content ?: return@withContext null,
|
||||
apkFileName = apk["name"]?.jsonPrimitive?.content ?: "cachette-$remote.apk",
|
||||
)
|
||||
}
|
||||
|
||||
/** Télécharge l'APK dans le cache de l'application et renvoie le fichier prêt à installer. */
|
||||
suspend fun downloadApk(update: UpdateInfo): File = withContext(Dispatchers.IO) {
|
||||
val directory = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
// Une version précédemment téléchargée n'a plus lieu d'être une fois la suivante demandée.
|
||||
directory.listFiles()?.forEach { if (it.name != update.apkFileName) it.delete() }
|
||||
|
||||
val target = File(directory, update.apkFileName)
|
||||
val partial = File(directory, "${update.apkFileName}.part")
|
||||
|
||||
val connection = authenticated(update.downloadUrl)
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Téléchargement refusé (${connection.responseCode})")
|
||||
}
|
||||
connection.inputStream.use { input ->
|
||||
FileOutputStream(partial).use { output -> input.copyTo(output) }
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
// Renommage final : un fichier .apk présent est donc toujours un fichier complet.
|
||||
if (target.exists()) target.delete()
|
||||
check(partial.renameTo(target)) { "Impossible de finaliser le téléchargement" }
|
||||
target
|
||||
}
|
||||
|
||||
private fun fetch(url: String): String {
|
||||
val connection = authenticated(url)
|
||||
return try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Le dépôt a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticated(url: String): HttpURLConnection =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 60_000
|
||||
setRequestProperty("User-Agent", "Cachette/$currentVersion (Android)")
|
||||
setRequestProperty("Authorization", "token ${BuildConfig.GITEA_UPDATE_TOKEN}")
|
||||
}
|
||||
|
||||
/** Comparaison numérique champ à champ : « 1.10.0 » est plus récent que « 1.9.0 ». */
|
||||
private fun isNewer(remote: String, current: String): Boolean {
|
||||
val r = remote.split('.').mapNotNull { it.toIntOrNull() }
|
||||
val c = current.split('.').mapNotNull { it.toIntOrNull() }
|
||||
for (i in 0 until maxOf(r.size, c.size)) {
|
||||
val remotePart = r.getOrElse(i) { 0 }
|
||||
val currentPart = c.getOrElse(i) { 0 }
|
||||
if (remotePart != currentPart) return remotePart > currentPart
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Seul le dossier des mises à jour est partagé, et uniquement avec l'installateur système. -->
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
Reference in new issue
Block a user