From adc6fc0486412aed9e186fd8e4bd43adf82c9240 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 13 Sep 2026 14:22:36 +0200 Subject: [PATCH] Guet sur toutes les stations, veille de fond et pochettes (1.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guetter un morceau supposait de savoir sur quelle station il passerait. On le nomme désormais sans plus : les quarante stations les plus écoutées sont balayées quatre à la fois, et l'enregistrement part dès que l'une le diffuse. Un écran sépare ce qu'on attend de ce qu'on a capté. Reste que la tâche périodique du système ne descend pas sous le quart d'heure, quand un morceau dure trois à quatre minutes : elle ne peut qu'en manquer. Une veille active, à enclencher, balaie toutes les trois minutes en service de premier plan, et s'arrête d'elle-même une fois le guet vide. L'écran expose ce compromis plutôt que de laisser croire à une surveillance continue gratuite. Les titres sans pochette en reçoivent une, cherchée sur leur nom. Le nom est nettoyé d'abord : un enregistrement radio traîne celui de sa station, qui fausserait la recherche. Le bouton d'actualisation de l'écran Hors ligne relance le scan et complète ce qui manque. L'écran de lecture dit enfin d'où vient ce qu'on écoute : playlist, source, taille, date. Un guet ne s'enregistrait pas : le champ était vidé avant que la coroutine n'en lise la valeur. Elle est capturée avant l'effacement. --- CHANGELOG.md | 26 ++ README.md | 4 +- VERSION | 2 +- app/src/main/AndroidManifest.xml | 5 + .../main/java/fr/tuxgyver/noxely/NoxelyApp.kt | 7 + .../tuxgyver/noxely/library/ArtworkFinder.kt | 61 ++++ .../tuxgyver/noxely/library/ImportedStore.kt | 9 + .../noxely/playback/PlayerController.kt | 8 +- .../fr/tuxgyver/noxely/radio/WatchScanner.kt | 101 +++++++ .../fr/tuxgyver/noxely/radio/WatchService.kt | 144 ++++++++++ .../fr/tuxgyver/noxely/radio/WatchStore.kt | 90 ++++++ .../java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt | 18 +- .../noxely/ui/offline/DownloadsScreen.kt | 53 +++- .../noxely/ui/player/NowPlayingScreen.kt | 42 ++- .../ui/playlists/PlaylistDetailScreen.kt | 6 +- .../tuxgyver/noxely/ui/radio/RadioScreen.kt | 7 + .../noxely/ui/radio/WatchListScreen.kt | 271 ++++++++++++++++++ 17 files changed, 845 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/fr/tuxgyver/noxely/library/ArtworkFinder.kt create mode 100644 app/src/main/java/fr/tuxgyver/noxely/radio/WatchScanner.kt create mode 100644 app/src/main/java/fr/tuxgyver/noxely/radio/WatchService.kt create mode 100644 app/src/main/java/fr/tuxgyver/noxely/radio/WatchStore.kt create mode 100644 app/src/main/java/fr/tuxgyver/noxely/ui/radio/WatchListScreen.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1227899..2f71962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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/). +## [1.7.0] — 2026-09-13 + +### Ajouté + +- **Guet sur l'ensemble des stations.** On nomme un morceau ou un artiste, sans désigner de + station : Noxely balaie les quarante stations les plus écoutées, quatre à la fois, et déclenche + l'enregistrement dès qu'une le diffuse. Un écran dédié sépare ce qui est attendu de ce qui a été + capté, avec le nombre de balayages et l'heure du dernier. +- **Veille en arrière-plan.** Deux mécanismes, parce qu'un seul ne suffit pas : une tâche + périodique tourne en permanence au quart d'heure — le minimum qu'autorise Android — et une + veille active, à enclencher, balaie toutes les trois minutes, écran éteint compris. Un morceau + durant trois à quatre minutes, la tâche périodique en manque forcément ; l'écran le dit au lieu + de le taire. La veille s'arrête d'elle-même quand plus rien n'est guetté. +- **Pochettes automatiques.** Les titres sans image en reçoivent une, cherchée sur leur nom via la + recherche publique d'iTunes, sans clé. Le nom est nettoyé au préalable : un enregistrement radio + traîne celui de sa station, qui brouillerait la requête. +- **Bouton d'actualisation** de l'écran Hors ligne : relance le scan de l'appareil et complète les + pochettes manquantes, par lots bornés pour ne pas marteler le service. +- L'écran de lecture affiche la provenance du morceau — nom de la playlist s'il en vient —, sa + source, sa taille et sa date. + +### Corrigé + +- Un morceau guetté n'était pas enregistré : le champ de saisie était vidé avant que la coroutine + n'en lise la valeur. Elle est désormais capturée avant l'effacement. + ## [1.6.0] — 2026-09-13 ### Changé diff --git a/README.md b/README.md index ae5b205..5a77fc4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-1.6.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-1.7.0-6B4EFF)](VERSION) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) Noxely est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les @@ -47,6 +47,8 @@ sans la moindre connexion. | ℹ️ **Page « À propos »** | Fonctions, garanties, pile technique, journal et auteur | | 👀 **Titre en cours** | Ce que diffuse chaque station, lu dans son flux | | ⏰ **Guet d'un titre** | L'enregistrement se déclenche quand le morceau voulu passe, et s'arrête à sa fin | +| 📡 **Guet sur toutes les stations** | Nommez un morceau : Noxely balaie l'annuaire jusqu'à le trouver | +| 🖼️ **Pochettes automatiques** | Les titres sans image en reçoivent une, cherchée sur leur nom | | 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation | --- diff --git a/VERSION b/VERSION index dc1e644..bd8bf88 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0 +1.7.0 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 39ba587..6b3ddcc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,11 @@ android:exported="false" android:foregroundServiceType="dataSync" /> + + MediaEntry?, ) { private var controller: MediaController? = null + private var queueLabel: String? = null private val _state = MutableStateFlow(PlayerUiState()) val state: StateFlow = _state.asStateFlow() @@ -81,9 +84,10 @@ class PlayerController( } /** Lance une file de lecture à partir de l'entrée choisie. */ - fun play(queue: List, startIndex: Int) { + fun play(queue: List, startIndex: Int, label: String? = null) { val controller = controller ?: return if (queue.isEmpty()) return + queueLabel = label controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L) controller.prepare() controller.play() @@ -108,6 +112,7 @@ class PlayerController( fun previous() = controller?.seekToPreviousMediaItem() ?: Unit fun stop() { + queueLabel = null controller?.stop() controller?.clearMediaItems() syncState() @@ -132,6 +137,7 @@ class PlayerController( durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L, hasNext = controller.hasNextMediaItem(), hasPrevious = controller.hasPreviousMediaItem(), + queueLabel = queueLabel, ) } } diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchScanner.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchScanner.kt new file mode 100644 index 0000000..41745e4 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchScanner.kt @@ -0,0 +1,101 @@ +package fr.tuxgyver.noxely.radio + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import fr.tuxgyver.noxely.NoxelyApp +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.util.concurrent.TimeUnit + +/** + * Résultat d'un balayage, tel que l'interface le rapporte. + */ +data class ScanOutcome( + val stationsProbed: Int, + val titlesRead: Int, + val matched: String?, + val matchedOn: String?, +) + +/** + * Passe les stations en revue à la recherche des morceaux guettés. + * + * Chaque station doit être ouverte pour livrer son titre : le balayage est donc borné aux + * stations les plus écoutées, par paquets, et non lancé en continu. + * + * ⚠️ Un morceau dure trois à quatre minutes, et WorkManager n'autorise pas de tâche périodique + * plus fréquente qu'un quart d'heure. Le balayage automatique ne peut donc pas tout attraper : + * il repère ce qui passe au moment où il regarde. Le balayage manuel existe pour cela. + */ +class WatchScanner( + private val directory: RadioDirectory, + private val store: WatchStore, +) { + suspend fun scan(context: Context, stationLimit: Int = STATION_LIMIT): ScanOutcome { + val pending = store.pending() + if (pending.isEmpty()) return ScanOutcome(0, 0, null, null) + + val stations = directory.popular().getOrNull()?.take(stationLimit).orEmpty() + var titles = 0 + + stations.chunked(CONCURRENCY).forEach { group -> + val heard = coroutineScope { + group.map { station -> async { station to directory.nowPlaying(station.streamUrl) } } + .awaitAll() + } + for ((station, title) in heard) { + if (title == null) continue + titles++ + val hit = pending.firstOrNull { title.contains(it.query, ignoreCase = true) } + ?: continue + + // Un seul enregistrement à la fois : inutile d'en réclamer un second. + if (!Recorder.status.value.isRecording) { + RecordingService.start(context, station, whenTitleContains = hit.query) + } + store.markFound(hit.id, station.name, title) + store.markScanned(pending.map { it.id }) + return ScanOutcome(stations.size, titles, title, station.name) + } + } + + store.markScanned(pending.map { it.id }) + return ScanOutcome(stations.size, titles, null, null) + } + + companion object { + private const val STATION_LIMIT = 40 + private const val CONCURRENCY = 4 + private const val WORK_NAME = "noxely-watch-scan" + + /** Un quart d'heure est le minimum autorisé pour une tâche périodique. */ + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + ) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + } +} + +class WatchWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val container = (applicationContext as NoxelyApp).container + if (container.watches.pending().isEmpty()) return Result.success() + runCatching { container.watchScanner.scan(applicationContext) } + return Result.success() + } +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchService.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchService.kt new file mode 100644 index 0000000..926fd54 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchService.kt @@ -0,0 +1,144 @@ +package fr.tuxgyver.noxely.radio + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import fr.tuxgyver.noxely.MainActivity +import fr.tuxgyver.noxely.NoxelyApp +import fr.tuxgyver.noxely.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Veille active : balaie les stations en continu tant qu'un morceau est guetté. + * + * La tâche périodique du système ne descend pas sous le quart d'heure, quand un morceau dure + * trois à quatre minutes : elle ne peut donc qu'en manquer. Cette veille tourne en service de + * premier plan, visible et interruptible, et s'arrête d'elle-même quand il n'y a plus rien à + * guetter — il n'y a pas de raison de consommer pour rien. + */ +class WatchService : Service() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var job: Job? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + finish() + return START_NOT_STICKY + } + if (job?.isActive == true) return START_STICKY + + val container = (application as NoxelyApp).container + running.value = true + startForegroundCompat(notification(container.watches.pending().size, null)) + + job = scope.launch { + while (isActive) { + val pending = container.watches.pending() + if (pending.isEmpty()) break + + val outcome = runCatching { container.watchScanner.scan(applicationContext) }.getOrNull() + notify(container.watches.pending().size, outcome?.matched) + + if (container.watches.pending().isEmpty()) break + delay(SWEEP_INTERVAL_MS) + } + finish() + } + return START_STICKY + } + + private fun finish() { + job?.cancel() + job = null + running.value = false + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { + running.value = false + scope.cancel() + super.onDestroy() + } + + private fun startForegroundCompat(notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun notify(pending: Int, lastMatch: String?) { + getSystemService(NotificationManager::class.java) + .notify(NOTIFICATION_ID, notification(pending, lastMatch)) + } + + private fun notification(pending: Int, lastMatch: String?): Notification { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Veille radio", NotificationManager.IMPORTANCE_LOW) + ) + } + val open = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val stop = PendingIntent.getService( + this, 1, + Intent(this, WatchService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_offline) + .setContentTitle("Veille radio · $pending morceau(x) guetté(s)") + .setContentText(lastMatch?.let { "Dernier capté : $it" } ?: "Balayage toutes les 3 minutes") + .setContentIntent(open) + .addAction(0, "Arrêter", stop) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + companion object { + private const val ACTION_STOP = "fr.tuxgyver.noxely.WATCH_STOP" + private const val NOTIFICATION_ID = 4501 + private const val CHANNEL_ID = "noxely_watch" + + /** Trois minutes : assez serré pour croiser un morceau, assez large pour la batterie. */ + private const val SWEEP_INTERVAL_MS = 180_000L + + private val running = MutableStateFlow(false) + val isRunning: StateFlow = running.asStateFlow() + + fun start(context: Context) { + context.startForegroundService(Intent(context, WatchService::class.java)) + } + + fun stop(context: Context) { + context.startService(Intent(context, WatchService::class.java).setAction(ACTION_STOP)) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchStore.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchStore.kt new file mode 100644 index 0000000..34dd4a8 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchStore.kt @@ -0,0 +1,90 @@ +package fr.tuxgyver.noxely.radio + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.util.UUID + +/** Un morceau guetté sur l'ensemble des stations. */ +@Serializable +data class Watch( + val id: String, + val query: String, + val createdAt: Long, + /** Renseignés une fois le morceau capté. */ + val foundAt: Long? = null, + val foundOn: String? = null, + val foundTitle: String? = null, + val lastScanAt: Long? = null, + val scans: Int = 0, +) { + val isFound: Boolean get() = foundAt != null +} + +/** Persistance des guets, sur le même principe que les playlists. */ +class WatchStore(context: Context, scope: CoroutineScope) { + + private val file = File(context.filesDir, "watches.json") + private val json = Json { ignoreUnknownKeys = true } + private val mutex = Mutex() + + private val _watches = MutableStateFlow>(emptyList()) + val watches: StateFlow> = _watches.asStateFlow() + + init { + scope.launch { _watches.value = read() } + } + + suspend fun add(query: String) = mutate { current -> + val cleaned = query.trim() + if (cleaned.isBlank() || current.any { it.query.equals(cleaned, true) && !it.isFound }) current + else current + Watch(UUID.randomUUID().toString(), cleaned, System.currentTimeMillis()) + } + + suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } } + + suspend fun markFound(id: String, station: String, title: String) = mutate { current -> + current.map { + if (it.id == id) it.copy(foundAt = System.currentTimeMillis(), foundOn = station, foundTitle = title) + else it + } + } + + suspend fun markScanned(ids: Collection) = mutate { current -> + val now = System.currentTimeMillis() + current.map { if (it.id in ids) it.copy(lastScanAt = now, scans = it.scans + 1) else it } + } + + /** Les guets encore en attente, seuls concernés par un balayage. */ + fun pending(): List = _watches.value.filterNot { it.isFound } + + private suspend fun mutate(transform: (List) -> List) = mutex.withLock { + val updated = transform(_watches.value) + if (updated != _watches.value) { + _watches.value = updated + write(updated) + } + } + + private suspend fun read(): List = withContext(Dispatchers.IO) { + if (!file.exists()) return@withContext emptyList() + runCatching { json.decodeFromString>(file.readText()) }.getOrElse { emptyList() } + } + + private suspend fun write(watches: List) = withContext(Dispatchers.IO) { + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(json.encodeToString(watches)) + tmp.renameTo(file) + } +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt index b133a16..9eca7c0 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt @@ -47,6 +47,7 @@ import fr.tuxgyver.noxely.ui.library.LibraryScreen import fr.tuxgyver.noxely.ui.offline.DownloadsScreen import fr.tuxgyver.noxely.ui.player.MiniPlayer import fr.tuxgyver.noxely.ui.radio.RadioScreen +import fr.tuxgyver.noxely.ui.radio.WatchListScreen import fr.tuxgyver.noxely.ui.playlists.AddToPlaylistSheet import fr.tuxgyver.noxely.ui.playlists.PlaylistDetailScreen import fr.tuxgyver.noxely.ui.playlists.PlaylistsScreen @@ -67,6 +68,7 @@ private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item" private const val PLAYLIST_ROUTE = "playlist/{playlist}" private const val RADIO_ROUTE = "radio" private const val ABOUT_ROUTE = "about" +private const val WATCHES_ROUTE = "watches" @Composable fun NoxelyRoot(container: AppContainer) { @@ -88,6 +90,10 @@ fun NoxelyRoot(container: AppContainer) { container.player.play(queue, index) expanded = true } + val playFrom: (List, Int, String?) -> Unit = { queue, index, label -> + container.player.play(queue, index, label) + expanded = true + } val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } } Box(Modifier.fillMaxSize()) { @@ -157,6 +163,16 @@ fun NoxelyRoot(container: AppContainer) { currentMediaId = playerState.mediaId, onBack = navController::popBackStack, onMessage = notify, + onOpenWatches = { navController.navigate(WATCHES_ROUTE) }, + contentPadding = padding, + ) + } + + composable(WATCHES_ROUTE) { + WatchListScreen( + container = container, + onBack = navController::popBackStack, + onMessage = notify, contentPadding = padding, ) } @@ -167,7 +183,7 @@ fun NoxelyRoot(container: AppContainer) { playlistId = entry.arguments?.getString("playlist").orEmpty(), currentMediaId = playerState.mediaId, onBack = navController::popBackStack, - onPlay = play, + onPlay = playFrom, contentPadding = padding, ) } diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/offline/DownloadsScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/offline/DownloadsScreen.kt index 320ddbc..314b179 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/offline/DownloadsScreen.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/offline/DownloadsScreen.kt @@ -15,7 +15,9 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -59,6 +61,8 @@ fun DownloadsScreen( val offlineStates by container.downloads.states.collectAsStateWithLifecycle() var confirmClear by remember { mutableStateOf(false) } var pendingRecordingDelete by remember { mutableStateOf(null) } + var refreshing by remember { mutableStateOf(false) } + var refreshNote by remember { mutableStateOf(null) } val context = LocalContext.current val scope = rememberCoroutineScope() var cacheSize by remember { mutableLongStateOf(0L) } @@ -97,7 +101,32 @@ fun DownloadsScreen( cacheSize = cacheSize, completed = completed, running = running, + refreshing = refreshing, + note = refreshNote, onClear = { confirmClear = true }, + onRefresh = { + refreshing = true + refreshNote = null + scope.launch { + container.library.refreshLocal() + // Les pochettes manquantes sont cherchées une par une : un enregistrement + // radio ou un fichier importé n'en a aucune tant qu'on ne l'a pas cherchée. + val missing = cached.filter { it.artworkUri.isNullOrBlank() } + var found = 0 + missing.take(MAX_ARTWORK_LOOKUPS).forEach { entry -> + container.artwork.find(entry)?.let { url -> + container.imported.setArtwork(entry.id, url) + found++ + } + } + refreshing = false + refreshNote = when { + missing.isEmpty() -> "Tout est à jour" + found == 0 -> "Aucune pochette trouvée pour ${missing.size} titre(s)" + else -> "$found pochette(s) ajoutée(s)" + } + } + }, ) Spacer(Modifier.size(14.dp)) } @@ -188,14 +217,18 @@ private fun StorageCard( cacheSize: Long, completed: Int, running: Int, + refreshing: Boolean, + note: String?, onClear: () -> Unit, + onRefresh: () -> Unit, ) { Surface( color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth(), ) { - Row( + Column { + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(20.dp), ) { @@ -212,13 +245,31 @@ private fun StorageCard( style = MaterialTheme.typography.bodyMedium, ) } + if (refreshing) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp)) + } else { + IconButton(onClick = onRefresh) { + Icon(Icons.Rounded.Refresh, contentDescription = "Actualiser et chercher les pochettes") + } + } if (completed > 0 || running > 0) { TextButton(onClick = onClear) { Text("Tout libérer") } } + } + note?.let { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 14.dp), + ) + } } } } +/** Chaque recherche est une requête réseau : on borne le lot pour ne pas marteler le service. */ +private const val MAX_ARTWORK_LOOKUPS = 25 + @Composable private fun EmptyOffline() { Box( diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/player/NowPlayingScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/player/NowPlayingScreen.kt index b8d3cb8..7f188d0 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/player/NowPlayingScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -60,6 +61,10 @@ import fr.tuxgyver.noxely.offline.OfflineState import fr.tuxgyver.noxely.playback.PlayerUiState import fr.tuxgyver.noxely.ui.components.Artwork import fr.tuxgyver.noxely.ui.components.formatDuration +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import fr.tuxgyver.noxely.ui.components.formatBytes @Composable fun NowPlayingScreen( @@ -170,7 +175,12 @@ fun NowPlayingScreen( overflow = TextOverflow.Ellipsis, ) - Spacer(Modifier.size(20.dp)) + entry?.let { current -> + Spacer(Modifier.size(10.dp)) + TrackDetails(entry = current, queueLabel = state.queueLabel) + } + + Spacer(Modifier.size(16.dp)) Slider( value = scrubbing ?: if (state.durationMs > 0) { @@ -264,6 +274,36 @@ private fun SkipButton( } } +/** + * Ce qu'on sait du morceau en cours : d'où il est lu, ce qu'il pèse, quand il est arrivé. + * Les champs absents ne laissent pas de trou — ils ne s'affichent simplement pas. + */ +@Composable +private fun TrackDetails(entry: MediaEntry, queueLabel: String?) { + val details = buildList { + queueLabel?.takeIf { it.isNotBlank() }?.let { add("📋 $it") } + add(entry.source.label) + entry.sizeBytes?.takeIf { it > 0 }?.let { add(formatBytes(it)) } + entry.addedAt.takeIf { it > 0 }?.let { + add(SimpleDateFormat("d MMM yyyy", Locale.FRANCE).format(Date(it))) + } + } + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + shape = MaterialTheme.shapes.small, + ) { + Text( + text = details.joinToString(" · "), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), + ) + } +} + @Composable private fun OfflineToggle( entry: MediaEntry?, diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/playlists/PlaylistDetailScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/playlists/PlaylistDetailScreen.kt index 3eee17b..d05f0c8 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/playlists/PlaylistDetailScreen.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/playlists/PlaylistDetailScreen.kt @@ -46,7 +46,7 @@ fun PlaylistDetailScreen( playlistId: String, currentMediaId: String?, onBack: () -> Unit, - onPlay: (List, Int) -> Unit, + onPlay: (List, Int, String?) -> Unit, contentPadding: PaddingValues, ) { val playlists by container.playlists.playlists.collectAsStateWithLifecycle() @@ -81,7 +81,7 @@ fun PlaylistDetailScreen( if (tracks.isNotEmpty()) { Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { - Button(onClick = { onPlay(tracks, 0) }) { + Button(onClick = { onPlay(tracks, 0, playlist?.name) }) { Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.size(8.dp)) Text("Tout lire") @@ -115,7 +115,7 @@ fun PlaylistDetailScreen( entry = entry, offline = offlineStates[entry.id], isCurrent = entry.id == currentMediaId, - onClick = { onPlay(tracks, tracks.indexOf(entry)) }, + onClick = { onPlay(tracks, tracks.indexOf(entry), playlist?.name) }, onDownload = { container.downloads.download(entry) }, onRemoveOffline = { container.downloads.remove(entry.id) }, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/radio/RadioScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/radio/RadioScreen.kt index 95945fc..0dda2b6 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/radio/RadioScreen.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/radio/RadioScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.rounded.FiberManualRecord import androidx.compose.material.icons.rounded.Alarm import androidx.compose.material.icons.rounded.GraphicEq import androidx.compose.material.icons.rounded.PlayCircleOutline +import androidx.compose.material.icons.rounded.Radar import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Stop import androidx.compose.material3.Button @@ -86,6 +87,7 @@ fun RadioScreen( currentMediaId: String?, onBack: () -> Unit, onMessage: (String) -> Unit, + onOpenWatches: () -> Unit, contentPadding: PaddingValues, ) { val context = LocalContext.current @@ -135,6 +137,11 @@ fun RadioScreen( Column(Modifier.fillMaxSize()) { TopAppBar( title = { Text("Radio") }, + actions = { + IconButton(onClick = onOpenWatches) { + Icon(Icons.Rounded.Radar, contentDescription = "Morceaux guettés") + } + }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour") diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/radio/WatchListScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/radio/WatchListScreen.kt new file mode 100644 index 0000000..c5d0370 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/radio/WatchListScreen.kt @@ -0,0 +1,271 @@ +package fr.tuxgyver.noxely.ui.radio + +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.HourglassEmpty +import androidx.compose.material.icons.rounded.Radar +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import fr.tuxgyver.noxely.AppContainer +import fr.tuxgyver.noxely.radio.Watch +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Les morceaux guettés sur l'ensemble des stations : ceux qu'on attend, et ceux qui sont passés. + * + * Le balayage automatique tourne au quart d'heure — le minimum qu'autorise le système. Un morceau + * durant trois à quatre minutes, il en manque forcément : le bouton « Balayer » sert à regarder + * tout de suite, quand on sait qu'un titre tourne. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WatchListScreen( + container: AppContainer, + onBack: () -> Unit, + onMessage: (String) -> Unit, + contentPadding: PaddingValues, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val watches by container.watches.watches.collectAsStateWithLifecycle() + + var query by remember { mutableStateOf("") } + var scanning by remember { mutableStateOf(false) } + val watching by fr.tuxgyver.noxely.radio.WatchService.isRunning.collectAsStateWithLifecycle() + + val pending = watches.filterNot { it.isFound } + val found = watches.filter { it.isFound } + val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()) + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("Guets") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour") + } + }, + actions = { + if (scanning) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(22.dp).padding(end = 8.dp), + ) + } else if (pending.isNotEmpty()) { + IconButton(onClick = { + scanning = true + scope.launch { + val outcome = runCatching { container.watchScanner.scan(context) }.getOrNull() + scanning = false + onMessage( + when { + outcome == null -> "Balayage impossible" + outcome.matched != null -> + "Trouvé sur ${outcome.matchedOn} : ${outcome.matched}" + else -> + "${outcome.stationsProbed} stations écoutées, ${outcome.titlesRead} titres lus, rien de guetté" + } + ) + } + }) { + Icon(Icons.Rounded.Radar, contentDescription = "Balayer maintenant") + } + } + }, + ) + + if (pending.isNotEmpty()) { + Surface( + color = if (watching) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(14.dp)) { + Column(Modifier.weight(1f)) { + Text( + text = if (watching) "Veille active" else "Veille en arrière-plan", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = if (watching) { + "Les stations sont balayées toutes les 3 minutes, écran éteint compris." + } else { + "Sans elle, le balayage n'a lieu qu'au quart d'heure : un morceau de " + + "trois minutes passe souvent entre deux." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(10.dp)) + Button(onClick = { + if (watching) fr.tuxgyver.noxely.radio.WatchService.stop(context) + else fr.tuxgyver.noxely.radio.WatchService.start(context) + }) { + Text(if (watching) "Arrêter" else "Activer") + } + } + } + Spacer(Modifier.size(8.dp)) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Titre ou artiste à guetter") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button( + enabled = query.isNotBlank(), + onClick = { + // La valeur est capturée avant d'être effacée : la coroutine lirait sinon + // un champ déjà vidé, et n'enregistrerait rien. + val wanted = query.trim() + scope.launch { container.watches.add(wanted) } + onMessage("« $wanted » est guetté sur toutes les stations") + query = "" + }, + ) { + Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + + LazyColumn( + contentPadding = listPadding, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + if (watches.isEmpty()) { + item { + Box( + Modifier.fillMaxWidth().padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Nommez un morceau : Noxely écoutera les stations les plus suivies " + + "et l'enregistrera dès qu'il passera.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } + + if (pending.isNotEmpty()) { + item { SectionTitle("En attente (${pending.size})") } + items(pending, key = { it.id }) { watch -> + WatchRow(watch) { scope.launch { container.watches.remove(watch.id) } } + } + } + + if (found.isNotEmpty()) { + item { SectionTitle("Trouvés (${found.size})") } + items(found, key = { it.id }) { watch -> + WatchRow(watch) { scope.launch { container.watches.remove(watch.id) } } + } + } + + item { Spacer(Modifier.size(24.dp)) } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Spacer(Modifier.size(10.dp)) + Text(text, style = MaterialTheme.typography.titleSmall) +} + +@Composable +private fun WatchRow(watch: Watch, onRemove: () -> Unit) { + Surface( + color = if (watch.isFound) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium, + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { + Icon( + imageVector = if (watch.isFound) Icons.Rounded.CheckCircle else Icons.Rounded.HourglassEmpty, + contentDescription = null, + tint = if (watch.isFound) MaterialTheme.colorScheme.secondary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + text = watch.query, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (watch.isFound) { + "Capté sur ${watch.foundOn} · ${watch.foundTitle?.take(48).orEmpty()}" + } else { + val last = watch.lastScanAt + ?.let { SimpleDateFormat("HH:mm", Locale.FRANCE).format(Date(it)) } + "${watch.scans} balayage(s)" + (last?.let { ", dernier à $it" } ?: ", pas encore balayé") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onRemove) { + Icon(Icons.Rounded.DeleteOutline, contentDescription = "Retirer ce guet") + } + } + } +}