1 Commits
Author SHA1 Message Date
tuxgyverandClaude Sonnet 5 6677f418ca Gestionnaire de fichiers dans la bibliothèque, boutons blancs sur la carte de lecture (2.6.0)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6pmSE168yjyf8vgV8mZx6
2026-09-20 23:20:42 +02:00
13 changed files with 602 additions and 9 deletions

No files matched your search

+29
View File
@@ -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/) Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
et le versionnage [SemVer](https://semver.org/lang/fr/). et le versionnage [SemVer](https://semver.org/lang/fr/).
## [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 ## [2.5.0] — 2026-09-20
### Ajouté ### Ajouté
+2 -1
View File
@@ -7,7 +7,7 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org)
[![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose)
[![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3)
[![Version](https://img.shields.io/badge/Version-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) [![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) [![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 | | 🔑 **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 | | 🎵 **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 | | 📚 **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 | | 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre |
| 🖼️ **Pochettes** | Cherchées automatiquement pour les titres qui n'en ont pas | | 🖼️ **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 | | ℹ️ **À propos et mises à jour** | Version, garanties, journal, et suivi des releases du dépôt |
+1 -1
View File
@@ -1 +1 @@
2.5.0 2.6.0
@@ -15,6 +15,7 @@ package fr.tuxgyver.noxely
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import fr.tuxgyver.noxely.library.ArtworkFinder import fr.tuxgyver.noxely.library.ArtworkFinder
import fr.tuxgyver.noxely.library.FileCleaner
import fr.tuxgyver.noxely.library.ImportedStore import fr.tuxgyver.noxely.library.ImportedStore
import fr.tuxgyver.noxely.library.LibraryRepository import fr.tuxgyver.noxely.library.LibraryRepository
import fr.tuxgyver.noxely.library.LocalMediaScanner import fr.tuxgyver.noxely.library.LocalMediaScanner
@@ -51,6 +52,7 @@ class AppContainer(context: Context) {
val serverSettings = ServerSettings(context, scope) val serverSettings = ServerSettings(context, scope)
val sync = SyncClient(context, serverSettings, imported) val sync = SyncClient(context, serverSettings, imported)
val cleaner = FileCleaner(context, library, downloads, sync)
@@ -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<MediaEntry>)
/**
* 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<MediaEntry>, alsoOnServer: Boolean): CleanResult {
var removed = 0
var freed = 0L
val denied = mutableListOf<MediaEntry>()
val serverIds = mutableListOf<String>()
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<MediaEntry>): 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"
}
@@ -62,6 +62,7 @@ class LocalMediaScanner(private val context: Context) {
MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.ALBUM_ID, MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.DATE_ADDED, MediaStore.Audio.Media.DATE_ADDED,
MediaStore.Audio.Media.SIZE,
) )
val entries = mutableListOf<MediaEntry>() val entries = mutableListOf<MediaEntry>()
context.contentResolver.query( context.contentResolver.query(
@@ -77,6 +78,7 @@ class LocalMediaScanner(private val context: Context) {
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED) val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getLong(idCol) val id = cursor.getLong(idCol)
val artist = cursor.getString(artistCol).orEmpty() val artist = cursor.getString(artistCol).orEmpty()
@@ -90,6 +92,7 @@ class LocalMediaScanner(private val context: Context) {
artworkUri = albumArtUri(cursor.getLong(albumCol)).toString(), artworkUri = albumArtUri(cursor.getLong(albumCol)).toString(),
durationMs = cursor.getLong(durationCol), durationMs = cursor.getLong(durationCol),
addedAt = cursor.getLong(addedCol) * 1000L, 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.BUCKET_DISPLAY_NAME,
MediaStore.Video.Media.DURATION, MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.DATE_ADDED, MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media.SIZE,
) )
val entries = mutableListOf<MediaEntry>() val entries = mutableListOf<MediaEntry>()
context.contentResolver.query(collection, projection, null, null, null)?.use { cursor -> 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 bucketCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME)
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION) val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED) val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED)
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getLong(idCol) val id = cursor.getLong(idCol)
val uri = ContentUris.withAppendedId(collection, id) val uri = ContentUris.withAppendedId(collection, id)
@@ -129,6 +134,7 @@ class LocalMediaScanner(private val context: Context) {
artworkUri = uri.toString(), artworkUri = uri.toString(),
durationMs = cursor.getLong(durationCol), durationMs = cursor.getLong(durationCol),
addedAt = cursor.getLong(addedCol) * 1000L, addedAt = cursor.getLong(addedCol) * 1000L,
sizeBytes = cursor.getLong(sizeCol).takeIf { it > 0 },
) )
} }
} }
@@ -188,6 +188,26 @@ class SyncClient(
SyncResult(added, failed, settings.config.value.lastResult) 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:<id>`) : 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<String>): 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 { private fun request(url: String, token: String?): String {
val connection = openConnection(url, readTimeoutMs = 30_000).apply { val connection = openConnection(url, readTimeoutMs = 30_000).apply {
token?.let { setRequestProperty("X-Noxely-Token", it) } token?.let { setRequestProperty("X-Noxely-Token", it) }
@@ -51,6 +51,7 @@ import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.ui.about.AboutScreen 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.library.LibraryScreen
import fr.tuxgyver.noxely.ui.offline.DownloadsScreen import fr.tuxgyver.noxely.ui.offline.DownloadsScreen
import fr.tuxgyver.noxely.ui.player.MiniPlayer 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 PLAYLIST_ROUTE = "playlist/{playlist}"
private const val ABOUT_ROUTE = "about" private const val ABOUT_ROUTE = "about"
private const val FILES_ROUTE = "files"
@Composable @Composable
fun NoxelyRoot(container: AppContainer) { fun NoxelyRoot(container: AppContainer) {
@@ -115,7 +117,9 @@ fun NoxelyRoot(container: AppContainer) {
NavigationBar { NavigationBar {
Tab.entries.forEach { tab -> Tab.entries.forEach { tab ->
NavigationBarItem( 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) }, onClick = { navController.switchTab(tab.route) },
icon = { Icon(tab.icon, contentDescription = null) }, icon = { Icon(tab.icon, contentDescription = null) },
label = { Text(tab.label) }, label = { Text(tab.label) },
@@ -132,6 +136,17 @@ fun NoxelyRoot(container: AppContainer) {
currentMediaId = playerState.mediaId, currentMediaId = playerState.mediaId,
onPlay = play, onPlay = play,
onAddToPlaylist = { playlistTarget = it }, 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, contentPadding = padding,
) )
} }
@@ -43,12 +43,16 @@ object AppInfo {
"ou forfait épuisé. Audio et vidéo, commandes au casque et sur l'écran verrouillé.", "ou forfait épuisé. Audio et vidéo, commandes au casque et sur l'écran verrouillé.",
"Se synchroniser sans y penser" to "Se synchroniser sans y penser" to
"Toutes les six heures, en Wi-Fi non facturé et batterie suffisante, l'application " + "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 "Laisser le travail au serveur" to
"Recherche dans les catalogues, guet des morceaux sur les radios, enregistrement des " + "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.", "directs : tout cela se pilote depuis l'interface web du serveur, pas depuis le mobile.",
"Organiser en playlists" to "Organiser en playlists" to
"Des listes nommées, alimentées d'un appui long, lues à la suite.", "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 "Retrouver les pochettes" to
"Les titres sans image en reçoivent une, cherchée sur leur nom.", "Les titres sans image en reçoivent une, cherchée sur leur nom.",
"Rester à jour" to "Rester à jour" to
@@ -70,6 +74,9 @@ object AppInfo {
"plus aucune radio, ne cherche plus et n'enregistre plus.", "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 " + "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.", "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 " + "Le contenu mis en cache est exclu des sauvegardes dans le nuage et des transferts " +
"d'appareil à appareil.", "d'appareil à appareil.",
"Une synchronisation partielle ne fait pas avancer le curseur : ce qui a échoué est " + "Une synchronisation partielle ne fait pas avancer le curseur : ce qui a échoué est " +
@@ -61,6 +61,8 @@ fun MediaRow(
selected: Boolean? = null, selected: Boolean? = null,
/** Appui long : ajout à une playlist. Absent là où le geste n'a pas de sens. */ /** Appui long : ajout à une playlist. Absent là où le geste n'a pas de sens. */
onLongClick: (() -> Unit)? = null, 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( val background by animateColorAsState(
targetValue = when { targetValue = when {
@@ -110,7 +112,8 @@ fun MediaRow(
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
) )
Text( Text(
text = " · ${formatDuration(entry.durationMs)}", text = " · ${formatDuration(entry.durationMs)}" +
(sizeLabel?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -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<SourceKind?>(null) }
var selected by remember { mutableStateOf(setOf<String>()) }
var confirming by remember { mutableStateOf(false) }
var alsoOnServer by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var awaitingConsent by remember { mutableStateOf<Pair<Consent, Boolean>?>(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)
}
}
}
@@ -29,12 +29,14 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons 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.LibraryMusic
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -72,6 +74,7 @@ fun LibraryScreen(
currentMediaId: String?, currentMediaId: String?,
onPlay: (List<MediaEntry>, Int) -> Unit, onPlay: (List<MediaEntry>, Int) -> Unit,
onAddToPlaylist: (MediaEntry) -> Unit, onAddToPlaylist: (MediaEntry) -> Unit,
onManageFiles: () -> Unit,
contentPadding: PaddingValues, contentPadding: PaddingValues,
) { ) {
val entries by container.library.entries.collectAsStateWithLifecycle() val entries by container.library.entries.collectAsStateWithLifecycle()
@@ -108,11 +111,19 @@ fun LibraryScreen(
) { ) {
item { item {
Column { Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 12.dp),
) {
Text( Text(
text = "Bibliothèque", text = "Bibliothèque",
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onManageFiles) {
Icon(Icons.Rounded.FolderOpen, contentDescription = "Gérer les fichiers")
}
}
Text( Text(
text = "${entries.size} éléments · ${offlineStates.values.count { it.isComplete }} mis de côté", text = "${entries.size} éléments · ${offlineStates.values.count { it.isComplete }} mis de côté",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -120,11 +121,17 @@ fun NowPlayingScreen(
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 24.dp), .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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
IconButton(onClick = onCollapse) { IconButton(onClick = onCollapse, colors = onCard) {
Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Réduire") Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Réduire")
} }
Text( Text(
@@ -134,10 +141,11 @@ fun NowPlayingScreen(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onAddToPlaylist) { IconButton(onClick = onAddToPlaylist, colors = onCard) {
Icon(Icons.Rounded.PlaylistAdd, contentDescription = "Ajouter à une playlist") 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") Icon(Icons.Rounded.Stop, contentDescription = "Arrêter la lecture")
} }
OfflineToggle(entry, offline, onDownload, onRemoveOffline) OfflineToggle(entry, offline, onDownload, onRemoveOffline)