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
This commit is contained in:
1 parent
5b5e90247f
commit
6677f418ca
13 files changed
+605
-12
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.ALBUM_ID,
|
||||
MediaStore.Audio.Media.DATE_ADDED,
|
||||
MediaStore.Audio.Media.SIZE,
|
||||
)
|
||||
val entries = mutableListOf<MediaEntry>()
|
||||
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<MediaEntry>()
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:<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 {
|
||||
val connection = openConnection(url, readTimeoutMs = 30_000).apply {
|
||||
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.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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.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<MediaEntry>, 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user