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/)
|
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é
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
[](https://developer.android.com/jetpack/compose)
|
[](https://developer.android.com/jetpack/compose)
|
||||||
[](https://developer.android.com/media/media3)
|
[](https://developer.android.com/media/media3)
|
||||||
[](VERSION)
|
[](VERSION)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](#-comment-ça-marche)
|
[](#-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 |
|
||||||
|
|||||||
@@ -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 {
|
||||||
Text(
|
Row(
|
||||||
text = "Bibliothèque",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
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(
|
||||||
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)
|
||||||
|
|||||||
Reference in new issue
Block a user