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)