Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6677f418ca |
No files matched your search
@@ -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é
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/jetpack/compose)
|
||||
[](https://developer.android.com/media/media3)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
[](LICENSE)
|
||||
[](#-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 |
|
||||
|
||||
@@ -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