From 6677f418caa51ad4319fdebc5c9e3067196c8eb5 Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Sun, 20 Sep 2026 23:20:42 +0200 Subject: [PATCH] =?UTF-8?q?Gestionnaire=20de=20fichiers=20dans=20la=20bibl?= =?UTF-8?q?ioth=C3=A8que,=20boutons=20blancs=20sur=20la=20carte=20de=20lec?= =?UTF-8?q?ture=20(2.6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La bibliothèque du mobile n'offrait aucun moyen de supprimer un fichier : les éléments reçus du serveur, rangés dans Music/Noxely, ne se retiraient pas, et l'écran Hors ligne ne proposait la suppression que pour les enregistrements. Un bouton dossier dans l'en-tête ouvre un écran Fichiers : provenance et taille de chaque élément, filtre par origine, tri par date, taille ou titre, sélection avec poids cumulé, suppression. Elle ne traite pas tout pareil : les fichiers que l'application a créés sont effacés, les liens et podcasts retirés avec leur copie hors ligne, et ceux de l'appareil — qui appartiennent à d'autres applications — passent toujours par la confirmation d'Android, depuis Android 11. Retirer aussi du serveur est une case décochée par défaut, car c'est irréversible. Le morceau en cours de lecture n'est pas sélectionnable, et un fichier déjà disparu est retiré de la liste au lieu d'y rester. Les fichiers de l'appareil affichent leur taille, lue de MediaStore. Sur la carte de lecture, réduire, playlist et stop passent en blanc, posés sur un disque sombre translucide pour rester lisibles dans le thème clair. Compilé et passé au lint de release ; l'écran n'a pas encore été essayé sur un appareil. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N6pmSE168yjyf8vgV8mZx6 --- CHANGELOG.md | 29 ++ README.md | 3 +- VERSION | 2 +- .../main/java/fr/tuxgyver/noxely/NoxelyApp.kt | 2 + .../fr/tuxgyver/noxely/library/FileCleaner.kt | 148 ++++++++ .../noxely/library/LocalMediaScanner.kt | 6 + .../fr/tuxgyver/noxely/sync/SyncClient.kt | 20 + .../java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt | 17 +- .../fr/tuxgyver/noxely/ui/about/AppInfo.kt | 9 +- .../tuxgyver/noxely/ui/components/MediaRow.kt | 5 +- .../tuxgyver/noxely/ui/files/FilesScreen.kt | 343 ++++++++++++++++++ .../noxely/ui/library/LibraryScreen.kt | 19 +- .../noxely/ui/player/NowPlayingScreen.kt | 14 +- 13 files changed, 605 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/fr/tuxgyver/noxely/library/FileCleaner.kt create mode 100644 app/src/main/java/fr/tuxgyver/noxely/ui/files/FilesScreen.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index bafb99d..2897a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Toutes les évolutions notables de Noxely 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/). +## [2.6.0] — 2026-09-20 + +### Ajouté + +- **Gestionnaire de fichiers.** Un bouton dossier dans l'en-tête de la bibliothèque ouvre l'écran + `Fichiers` : tout ce qui occupe l'appareil, avec sa provenance et sa taille, filtrable par + origine (serveur, enregistrements, appareil, liens, podcasts) et triable par date, taille ou + titre. On sélectionne, on voit le poids cumulé, on supprime. +- **Suppression selon ce qu'est l'élément**, et sans jamais confondre les cas : + - *reçus du serveur* et *enregistrements*, que l'application a créés : effacés du téléphone ; + - *liens* et *podcasts* : retirés de la bibliothèque, avec leur copie hors ligne ; + - *fichiers de l'appareil*, qui appartiennent à d'autres applications : Android demande + toujours confirmation, et la suppression n'est proposée que depuis Android 11. +- **« Supprimer aussi du serveur »**, case à cocher décochée par défaut, pour les éléments reçus + du serveur : la suppression y est irréversible, elle ne se fait donc que sur demande. +- Les fichiers de l'appareil affichent désormais leur taille, lue de MediaStore. + +### Modifié + +- **Boutons de la carte de lecture en blanc** — réduire, ajouter à une playlist, arrêter —, posés + sur un disque sombre translucide pour rester lisibles sur le fond clair du thème clair. + +### Notes + +- Un élément en cours de lecture ne peut pas être sélectionné : le supprimer sous le lecteur le + ferait échouer. +- Un fichier déjà disparu du disque est retiré de la liste au lieu d'y rester comme une ligne + impossible à supprimer. + ## [2.5.0] — 2026-09-20 ### Ajouté diff --git a/README.md b/README.md index 490c243..6286f20 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-2.5.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-2.6.0-6B4EFF)](VERSION) [![Licence](https://img.shields.io/badge/Licence-GPL%20v3-3FD8C0)](LICENSE) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) @@ -65,6 +65,7 @@ synchronisations : c'est là qu'est l'économie. | 🔑 **Un jeton par appareil** | Chacun le sien, révocable seul depuis l'onglet Appareils | | 🎵 **Lecture** | Audio et vidéo, arrière-plan, casque, écran verrouillé, ±15 s, stop | | 📚 **Bibliothèque** | Médias reçus du serveur et contenu déjà présent sur l'appareil | +| 🗂️ **Gestionnaire de fichiers** | Provenance et taille de chaque élément, tri par poids, suppression de la sélection — du téléphone, et du serveur si on le coche | | 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre | | 🖼️ **Pochettes** | Cherchées automatiquement pour les titres qui n'en ont pas | | ℹ️ **À propos et mises à jour** | Version, garanties, journal, et suivi des releases du dépôt | diff --git a/VERSION b/VERSION index 437459c..e70b452 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.0 +2.6.0 diff --git a/app/src/main/java/fr/tuxgyver/noxely/NoxelyApp.kt b/app/src/main/java/fr/tuxgyver/noxely/NoxelyApp.kt index c08975f..2ed1647 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/NoxelyApp.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/NoxelyApp.kt @@ -15,6 +15,7 @@ package fr.tuxgyver.noxely import android.app.Application import android.content.Context import fr.tuxgyver.noxely.library.ArtworkFinder +import fr.tuxgyver.noxely.library.FileCleaner import fr.tuxgyver.noxely.library.ImportedStore import fr.tuxgyver.noxely.library.LibraryRepository import fr.tuxgyver.noxely.library.LocalMediaScanner @@ -51,6 +52,7 @@ class AppContainer(context: Context) { val serverSettings = ServerSettings(context, scope) val sync = SyncClient(context, serverSettings, imported) + val cleaner = FileCleaner(context, library, downloads, sync) diff --git a/app/src/main/java/fr/tuxgyver/noxely/library/FileCleaner.kt b/app/src/main/java/fr/tuxgyver/noxely/library/FileCleaner.kt new file mode 100644 index 0000000..a479de6 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/library/FileCleaner.kt @@ -0,0 +1,148 @@ +/* + * Noxely — collecte côté serveur, écoute hors connexion côté mobile. + * Copyright © 2026 Johnny (tuxgyver) + * + * Ce programme est un logiciel libre : vous pouvez le redistribuer et le modifier + * selon les termes de la GNU General Public License, version 3 ou ultérieure, + * telle que publiée par la Free Software Foundation. + * + * Il est distribué dans l'espoir d'être utile, mais SANS AUCUNE GARANTIE. + * Voir le fichier LICENSE à la racine du dépôt pour le texte complet. + */ + +package fr.tuxgyver.noxely.library + +import android.content.Context +import android.content.IntentSender +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import fr.tuxgyver.noxely.model.MediaEntry +import fr.tuxgyver.noxely.model.SourceKind +import fr.tuxgyver.noxely.offline.DownloadTracker +import fr.tuxgyver.noxely.sync.SyncClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** Ce qu'une suppression a réellement fait, pour le dire à l'utilisateur sans rien enjoliver. */ +data class CleanResult( + val removed: Int, + val freedBytes: Long, + /** Éléments qu'il a été impossible de supprimer. */ + val failed: Int, + /** Éléments supprimés aussi du serveur. */ + val onServer: Int, + /** Confirmation du système à obtenir pour le reste, ou null s'il n'y en a pas. */ + val consent: Consent?, +) + +/** Suppression en attente de l'accord du système : Android l'exige pour les fichiers d'autrui. */ +class Consent(val sender: IntentSender, val entries: List) + +/** + * Supprime des éléments de la bibliothèque, selon ce que chacun est réellement. + * + * Les quatre sortes ne se traitent pas de la même façon, et le confondre serait dangereux : + * - reçus du serveur et enregistrements : des fichiers que l'application a créés, qu'elle efface + * sans confirmation ; + * - liens et podcasts : de simples références, retirées avec leur copie hors ligne ; + * - contenu de l'appareil : des fichiers qui appartiennent à d'autres applications. Android n'en + * laisse supprimer aucun sans la confirmation de l'utilisateur, et nous ne la contournons pas. + */ +class FileCleaner( + private val context: Context, + private val library: LibraryRepository, + private val downloads: DownloadTracker, + private val sync: SyncClient, +) { + private enum class Outcome { GONE, DENIED } + + /** Le contenu de l'appareil ne se supprime que depuis Android 11, par la boîte de dialogue système. */ + val canDeleteDeviceFiles: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + + suspend fun delete(entries: List, alsoOnServer: Boolean): CleanResult { + var removed = 0 + var freed = 0L + val denied = mutableListOf() + val serverIds = mutableListOf() + + for (entry in entries) { + when (entry.source) { + SourceKind.URL, SourceKind.PODCAST -> { + downloads.remove(entry.id) + library.forget(entry.id) + removed++ + } + SourceKind.SERVER, SourceKind.RECORDING -> when (removeOwnFile(entry)) { + Outcome.GONE -> { + library.forget(entry.id) + removed++ + freed += entry.sizeBytes ?: 0L + if (entry.source == SourceKind.SERVER) serverIds += entry.id + } + Outcome.DENIED -> denied += entry + } + SourceKind.LOCAL -> denied += entry + } + } + + val onServer = if (alsoOnServer && serverIds.isNotEmpty()) sync.deleteOnServer(serverIds) else 0 + val consent = consentFor(denied) + // Sans confirmation possible (Android 10 et avant, ou fichier sans adresse système), + // ces éléments restent : on les compte en échec plutôt que de les faire disparaître. + val failed = if (consent == null) denied.size else denied.count { !it.hasSystemUri() } + return CleanResult(removed, freed, failed, onServer, consent) + } + + /** + * À appeler quand le système a accepté la suppression : les fichiers ont disparu, il reste à + * les retirer de la bibliothèque et, si demandé, du serveur. + */ + suspend fun finish(consent: Consent, alsoOnServer: Boolean): Int { + val owned = consent.entries.filter { it.source != SourceKind.LOCAL } + owned.forEach { library.forget(it.id) } + if (alsoOnServer) { + sync.deleteOnServer(owned.filter { it.source == SourceKind.SERVER }.map { it.id }) + } + if (consent.entries.any { it.source == SourceKind.LOCAL }) library.refreshLocal() + return consent.entries.count { it.hasSystemUri() } + } + + /** + * Efface un fichier que l'application a créé. « Introuvable » vaut « supprimé » : un fichier + * déjà parti ne doit pas rester dans la bibliothèque comme une ligne qu'on ne peut plus retirer. + */ + private suspend fun removeOwnFile(entry: MediaEntry): Outcome = withContext(Dispatchers.IO) { + try { + val uri = Uri.parse(entry.uri) + when (uri.scheme) { + "content" -> { + context.contentResolver.delete(uri, null, null) + Outcome.GONE + } + "file" -> { + val file = File(requireNotNull(uri.path)) + if (!file.exists() || file.delete()) Outcome.GONE else Outcome.DENIED + } + else -> Outcome.DENIED + } + } catch (_: Exception) { + // Dont SecurityException : après une réinstallation, MediaStore ne nous reconnaît + // plus comme propriétaire de nos propres fichiers. + Outcome.DENIED + } + } + + private fun consentFor(entries: List): Consent? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null + val deletable = entries.filter { it.hasSystemUri() } + if (deletable.isEmpty()) return null + val request = MediaStore.createDeleteRequest( + context.contentResolver, deletable.map { Uri.parse(it.uri) }, + ) + return Consent(request.intentSender, deletable) + } + + private fun MediaEntry.hasSystemUri(): Boolean = Uri.parse(uri).scheme == "content" +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/library/LocalMediaScanner.kt b/app/src/main/java/fr/tuxgyver/noxely/library/LocalMediaScanner.kt index 9754ce7..c7b3cb1 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/library/LocalMediaScanner.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/library/LocalMediaScanner.kt @@ -62,6 +62,7 @@ class LocalMediaScanner(private val context: Context) { MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.ALBUM_ID, MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.SIZE, ) val entries = mutableListOf() context.contentResolver.query( @@ -77,6 +78,7 @@ class LocalMediaScanner(private val context: Context) { val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED) + val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) while (cursor.moveToNext()) { val id = cursor.getLong(idCol) val artist = cursor.getString(artistCol).orEmpty() @@ -90,6 +92,7 @@ class LocalMediaScanner(private val context: Context) { artworkUri = albumArtUri(cursor.getLong(albumCol)).toString(), durationMs = cursor.getLong(durationCol), addedAt = cursor.getLong(addedCol) * 1000L, + sizeBytes = cursor.getLong(sizeCol).takeIf { it > 0 }, ) } } @@ -108,6 +111,7 @@ class LocalMediaScanner(private val context: Context) { MediaStore.Video.Media.BUCKET_DISPLAY_NAME, MediaStore.Video.Media.DURATION, MediaStore.Video.Media.DATE_ADDED, + MediaStore.Video.Media.SIZE, ) val entries = mutableListOf() context.contentResolver.query(collection, projection, null, null, null)?.use { cursor -> @@ -116,6 +120,7 @@ class LocalMediaScanner(private val context: Context) { val bucketCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME) val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION) val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED) + val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE) while (cursor.moveToNext()) { val id = cursor.getLong(idCol) val uri = ContentUris.withAppendedId(collection, id) @@ -129,6 +134,7 @@ class LocalMediaScanner(private val context: Context) { artworkUri = uri.toString(), durationMs = cursor.getLong(durationCol), addedAt = cursor.getLong(addedCol) * 1000L, + sizeBytes = cursor.getLong(sizeCol).takeIf { it > 0 }, ) } } diff --git a/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt index abc819c..ae71f9c 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt @@ -188,6 +188,26 @@ class SyncClient( SyncResult(added, failed, settings.config.value.lastResult) } + /** + * Supprime des médias du serveur, et renvoie le nombre réellement supprimé. + * + * Les identifiants sont ceux de la bibliothèque du téléphone (`srv:`) : tout ce qui n'en + * porte pas le préfixe est ignoré. Un échec sur l'un n'empêche pas les autres. + */ + suspend fun deleteOnServer(entryIds: List): Int = withContext(Dispatchers.IO) { + val config = settings.config.value + if (!config.isConfigured) return@withContext 0 + val base = config.baseUrl.trimEnd('/') + entryIds.filter { it.startsWith("srv:") }.count { entryId -> + runCatching { + val connection = openConnection( + "$base/api/media/${entryId.removePrefix("srv:")}", method = "DELETE", + ).apply { setRequestProperty("X-Noxely-Token", config.token) } + try { connection.responseCode in 200..299 } finally { connection.disconnect() } + }.getOrDefault(false) + } + } + private fun request(url: String, token: String?): String { val connection = openConnection(url, readTimeoutMs = 30_000).apply { token?.let { setRequestProperty("X-Noxely-Token", it) } diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt index 6347b60..627b70f 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt @@ -51,6 +51,7 @@ import fr.tuxgyver.noxely.AppContainer import fr.tuxgyver.noxely.model.MediaEntry import fr.tuxgyver.noxely.model.SourceKind import fr.tuxgyver.noxely.ui.about.AboutScreen +import fr.tuxgyver.noxely.ui.files.FilesScreen import fr.tuxgyver.noxely.ui.library.LibraryScreen import fr.tuxgyver.noxely.ui.offline.DownloadsScreen import fr.tuxgyver.noxely.ui.player.MiniPlayer @@ -70,6 +71,7 @@ private enum class Tab(val route: String, val label: String, val icon: ImageVect private const val PLAYLIST_ROUTE = "playlist/{playlist}" private const val ABOUT_ROUTE = "about" +private const val FILES_ROUTE = "files" @Composable fun NoxelyRoot(container: AppContainer) { @@ -115,7 +117,9 @@ fun NoxelyRoot(container: AppContainer) { NavigationBar { Tab.entries.forEach { tab -> NavigationBarItem( - selected = currentRoute == tab.route, + // Le gestionnaire de fichiers s'ouvre depuis la bibliothèque : son onglet reste allumé. + selected = currentRoute == tab.route || + (tab == Tab.LIBRARY && currentRoute == FILES_ROUTE), onClick = { navController.switchTab(tab.route) }, icon = { Icon(tab.icon, contentDescription = null) }, label = { Text(tab.label) }, @@ -132,6 +136,17 @@ fun NoxelyRoot(container: AppContainer) { currentMediaId = playerState.mediaId, onPlay = play, onAddToPlaylist = { playlistTarget = it }, + onManageFiles = { navController.navigate(FILES_ROUTE) }, + contentPadding = padding, + ) + } + + composable(FILES_ROUTE) { + FilesScreen( + container = container, + currentMediaId = playerState.mediaId, + onBack = navController::popBackStack, + onMessage = notify, contentPadding = padding, ) } diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/about/AppInfo.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/about/AppInfo.kt index 5139521..f044620 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/about/AppInfo.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/about/AppInfo.kt @@ -43,12 +43,16 @@ object AppInfo { "ou forfait épuisé. Audio et vidéo, commandes au casque et sur l'écran verrouillé.", "Se synchroniser sans y penser" to "Toutes les six heures, en Wi-Fi non facturé et batterie suffisante, l'application " + - "demande au serveur ce qui est neuf et le récupère. Le reste du temps, elle dort.", + "demande au serveur ce qui est neuf et le récupère. Le reste du temps, elle dort. " + + "Un bouton lance aussi la synchronisation à la demande.", "Laisser le travail au serveur" to "Recherche dans les catalogues, guet des morceaux sur les radios, enregistrement des " + "directs : tout cela se pilote depuis l'interface web du serveur, pas depuis le mobile.", "Organiser en playlists" to "Des listes nommées, alimentées d'un appui long, lues à la suite.", + "Faire de la place" to + "Le gestionnaire de fichiers, ouvert depuis la bibliothèque, liste tout ce qui occupe " + + "l'appareil avec sa provenance et sa taille, trie par poids et supprime la sélection.", "Retrouver les pochettes" to "Les titres sans image en reçoivent une, cherchée sur leur nom.", "Rester à jour" to @@ -70,6 +74,9 @@ object AppInfo { "plus aucune radio, ne cherche plus et n'enregistre plus.", "Le cache ne contient que ce que vous avez demandé à garder : rien n'y est écrit pendant " + "une écoute en ligne, et rien n'en est effacé sans votre geste.", + "Un fichier qui appartient à une autre application ne se supprime jamais sans la " + + "confirmation du système ; les fichiers reçus du serveur ne sont retirés du serveur " + + "que si vous le cochez.", "Le contenu mis en cache est exclu des sauvegardes dans le nuage et des transferts " + "d'appareil à appareil.", "Une synchronisation partielle ne fait pas avancer le curseur : ce qui a échoué est " + diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/components/MediaRow.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/components/MediaRow.kt index d7107e4..1c2b1ff 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/components/MediaRow.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/components/MediaRow.kt @@ -61,6 +61,8 @@ fun MediaRow( selected: Boolean? = null, /** Appui long : ajout à une playlist. Absent là où le geste n'a pas de sens. */ onLongClick: (() -> Unit)? = null, + /** Taille à afficher après la durée, là où elle sert à choisir (gestionnaire de fichiers). */ + sizeLabel: String? = null, ) { val background by animateColorAsState( targetValue = when { @@ -110,7 +112,8 @@ fun MediaRow( modifier = Modifier.weight(1f, fill = false), ) Text( - text = " · ${formatDuration(entry.durationMs)}", + text = " · ${formatDuration(entry.durationMs)}" + + (sizeLabel?.let { " · $it" } ?: ""), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/files/FilesScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/files/FilesScreen.kt new file mode 100644 index 0000000..7f54da7 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/files/FilesScreen.kt @@ -0,0 +1,343 @@ +/* + * Noxely — collecte côté serveur, écoute hors connexion côté mobile. + * Copyright © 2026 Johnny (tuxgyver) + * + * Ce programme est un logiciel libre : vous pouvez le redistribuer et le modifier + * selon les termes de la GNU General Public License, version 3 ou ultérieure, + * telle que publiée par la Free Software Foundation. + * + * Il est distribué dans l'espoir d'être utile, mais SANS AUCUNE GARANTIE. + * Voir le fichier LICENSE à la racine du dépôt pour le texte complet. + */ + +package fr.tuxgyver.noxely.ui.files + +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll +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.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.FolderOpen +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +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.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.saveable.rememberSaveable +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import fr.tuxgyver.noxely.AppContainer +import fr.tuxgyver.noxely.library.Consent +import fr.tuxgyver.noxely.model.MediaEntry +import fr.tuxgyver.noxely.model.SourceKind +import fr.tuxgyver.noxely.offline.OfflineState +import fr.tuxgyver.noxely.ui.components.MediaRow +import fr.tuxgyver.noxely.ui.components.formatBytes +import kotlinx.coroutines.launch + +private enum class SortKey(val label: String) { + RECENT("Récents"), + SIZE("Taille"), + TITLE("Titre"), +} + +/** + * Gestionnaire de fichiers : voir ce qui occupe la place, et le supprimer. + * + * Chaque ligne annonce sa provenance et sa taille, car c'est ce qui permet de choisir. La + * suppression, elle, dépend de ce qu'est l'élément — voir [fr.tuxgyver.noxely.library.FileCleaner]. + */ +@Composable +fun FilesScreen( + container: AppContainer, + currentMediaId: String?, + onBack: () -> Unit, + onMessage: (String) -> Unit, + contentPadding: PaddingValues, +) { + val entries by container.library.entries.collectAsStateWithLifecycle() + val offlineStates by container.downloads.states.collectAsStateWithLifecycle() + val serverSettings by container.serverSettings.config.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + var sort by rememberSaveable { mutableStateOf(SortKey.RECENT) } + var source by rememberSaveable { mutableStateOf(null) } + var selected by remember { mutableStateOf(setOf()) } + var confirming by remember { mutableStateOf(false) } + var alsoOnServer by remember { mutableStateOf(false) } + var busy by remember { mutableStateOf(false) } + var awaitingConsent by remember { mutableStateOf?>(null) } + + val consentLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartIntentSenderForResult() + ) { result -> + val pending = awaitingConsent + awaitingConsent = null + if (pending == null) return@rememberLauncherForActivityResult + if (result.resultCode == Activity.RESULT_OK) { + scope.launch { + val count = container.cleaner.finish(pending.first, pending.second) + val freed = pending.first.entries.sumOf { it.sizeBytes ?: 0L } + onMessage("$count fichier(s) supprimé(s) · ${formatBytes(freed)} libérés") + } + } else { + onMessage("Suppression annulée : ces fichiers appartiennent à d'autres applications") + } + } + + fun sizeOf(entry: MediaEntry, offline: OfflineState?): Long = + if (entry.isRemote) offline?.takeIf { it.isComplete }?.bytesDownloaded ?: 0L + else entry.sizeBytes ?: 0L + + val sources = remember(entries) { entries.map { it.source }.distinct() } + val visible = remember(entries, offlineStates, sort, source) { + entries + .filter { source == null || it.source == source } + .let { list -> + when (sort) { + SortKey.RECENT -> list.sortedByDescending { it.addedAt } + SortKey.SIZE -> list.sortedByDescending { sizeOf(it, offlineStates[it.id]) } + SortKey.TITLE -> list.sortedBy { it.title.lowercase() } + } + } + } + val chosen = remember(entries, selected) { entries.filter { it.id in selected } } + val chosenBytes = chosen.sumOf { sizeOf(it, offlineStates[it.id]) } + val totalBytes = entries.sumOf { sizeOf(it, offlineStates[it.id]) } + + Column( + Modifier + .fillMaxSize() + .padding(top = contentPadding.calculateTopPadding()) + .padding(horizontal = 16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour") + } + Column(Modifier.weight(1f)) { + Text("Fichiers", style = MaterialTheme.typography.headlineSmall) + Text( + text = "${entries.size} éléments · ${formatBytes(totalBytes)} sur l'appareil", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + FilterChip(selected = source == null, onClick = { source = null }, label = { Text("Tout") }) + sources.forEach { kind -> + FilterChip( + selected = source == kind, + onClick = { source = kind }, + label = { Text(kind.label) }, + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) { + Text("Trier :", style = MaterialTheme.typography.labelMedium) + SortKey.entries.forEach { key -> + FilterChip(selected = sort == key, onClick = { sort = key }, label = { Text(key.label) }) + } + } + + Spacer(Modifier.size(8.dp)) + + if (selected.isNotEmpty()) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 6.dp, bottom = 6.dp), + ) { + Column(Modifier.weight(1f)) { + Text("${chosen.size} sélectionné(s)", style = MaterialTheme.typography.titleSmall) + Text(formatBytes(chosenBytes), style = MaterialTheme.typography.bodySmall) + } + TextButton(onClick = { + selected = visible.filter { it.id != currentMediaId }.map { it.id }.toSet() + }) { Text("Tout") } + TextButton(onClick = { selected = emptySet() }) { Text("Annuler") } + if (busy) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp)) + } else { + Button( + onClick = { alsoOnServer = false; confirming = true }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { Text("Supprimer") } + } + } + } + } else { + Text( + text = "Touchez des éléments pour les sélectionner, puis supprimez-les.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + + Spacer(Modifier.size(8.dp)) + + LazyColumn( + contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f), + ) { + if (visible.isEmpty()) { + item { EmptyFiles() } + } + items(visible, key = { it.id }) { entry -> + val offline = offlineStates[entry.id] + val bytes = sizeOf(entry, offline) + MediaRow( + entry = entry, + offline = offline, + isCurrent = entry.id == currentMediaId, + onClick = { + if (entry.id == currentMediaId) { + onMessage("En cours de lecture : arrêtez-la avant de la supprimer") + } else { + selected = if (entry.id in selected) selected - entry.id else selected + entry.id + } + }, + onDownload = {}, + onRemoveOffline = {}, + selected = entry.id in selected, + sizeLabel = entry.source.label + if (bytes > 0) " · ${formatBytes(bytes)}" else "", + ) + } + } + } + + if (confirming) { + val server = chosen.count { it.source == SourceKind.SERVER } + val recordings = chosen.count { it.source == SourceKind.RECORDING } + val links = chosen.count { it.isRemote } + val device = chosen.count { it.source == SourceKind.LOCAL } + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text("Supprimer ${chosen.size} élément(s) ?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("${formatBytes(chosenBytes)} seront libérés. Cette action est définitive.") + if (server > 0) Text("• $server reçu(s) du serveur : effacés du téléphone.") + if (recordings > 0) Text("• $recordings enregistrement(s) : effacés, sans retour possible.") + if (links > 0) Text("• $links lien(s) ou podcast(s) : retirés de la bibliothèque et de la mémoire hors ligne.") + if (device > 0) { + Text( + if (container.cleaner.canDeleteDeviceFiles) + "• $device fichier(s) de l'appareil : Android vous demandera de confirmer." + else + "• $device fichier(s) de l'appareil : impossibles à supprimer d'ici avant Android 11." + ) + } + if (server > 0 && serverSettings.isConfigured) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Checkbox(checked = alsoOnServer, onCheckedChange = { alsoOnServer = it }) + Text("Supprimer aussi du serveur (irréversible)") + } + } + } + }, + confirmButton = { + TextButton(onClick = { + confirming = false + val targets = chosen + val withServer = alsoOnServer + busy = true + scope.launch { + val result = runCatching { container.cleaner.delete(targets, withServer) } + busy = false + selected = emptySet() + result.onFailure { onMessage(it.message ?: "Suppression impossible") } + result.onSuccess { done -> + onMessage( + buildString { + append("${done.removed} supprimé(s) · ${formatBytes(done.freedBytes)} libérés") + if (done.onServer > 0) append(" · ${done.onServer} sur le serveur") + if (done.failed > 0) append(" · ${done.failed} impossible(s)") + } + ) + done.consent?.let { consent -> + awaitingConsent = consent to withServer + consentLauncher.launch(IntentSenderRequest.Builder(consent.sender).build()) + } + } + } + }) { Text("Supprimer", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { confirming = false }) { Text("Annuler") } }, + ) + } +} + +@Composable +private fun EmptyFiles() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 56.dp), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Rounded.FolderOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.size(12.dp)) + Text("Aucun fichier", style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/library/LibraryScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/library/LibraryScreen.kt index d2d4ac6..ba8b722 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/library/LibraryScreen.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/library/LibraryScreen.kt @@ -29,12 +29,14 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.FolderOpen import androidx.compose.material.icons.rounded.LibraryMusic import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Button import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -72,6 +74,7 @@ fun LibraryScreen( currentMediaId: String?, onPlay: (List, Int) -> Unit, onAddToPlaylist: (MediaEntry) -> Unit, + onManageFiles: () -> Unit, contentPadding: PaddingValues, ) { val entries by container.library.entries.collectAsStateWithLifecycle() @@ -108,11 +111,19 @@ fun LibraryScreen( ) { item { Column { - Text( - text = "Bibliothèque", - style = MaterialTheme.typography.displaySmall, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp), - ) + ) { + Text( + text = "Bibliothèque", + style = MaterialTheme.typography.displaySmall, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onManageFiles) { + Icon(Icons.Rounded.FolderOpen, contentDescription = "Gérer les fichiers") + } + } Text( text = "${entries.size} éléments · ${offlineStates.values.count { it.isComplete }} mis de côté", style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/player/NowPlayingScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/player/NowPlayingScreen.kt index e8517c3..de24e36 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/player/NowPlayingScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth 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.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -120,11 +121,17 @@ fun NowPlayingScreen( .navigationBarsPadding() .padding(horizontal = 24.dp), ) { + // Icônes blanches sur un disque sombre translucide : lisibles sur le fond violet clair du + // thème clair comme sur le fond sombre du thème sombre. + val onCard = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.35f), + contentColor = Color.White, + ) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - IconButton(onClick = onCollapse) { + IconButton(onClick = onCollapse, colors = onCard) { Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Réduire") } Text( @@ -134,10 +141,11 @@ fun NowPlayingScreen( textAlign = TextAlign.Center, modifier = Modifier.weight(1f), ) - IconButton(onClick = onAddToPlaylist) { + IconButton(onClick = onAddToPlaylist, colors = onCard) { Icon(Icons.Rounded.PlaylistAdd, contentDescription = "Ajouter à une playlist") } - IconButton(onClick = onStop) { + Spacer(Modifier.width(8.dp)) + IconButton(onClick = onStop, colors = onCard) { Icon(Icons.Rounded.Stop, contentDescription = "Arrêter la lecture") } OfflineToggle(entry, offline, onDownload, onRemoveOffline)