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 @@
[](https://kotlinlang.org)
[](https://developer.android.com/jetpack/compose)
[](https://developer.android.com/media/media3)
-[](VERSION)
+[](VERSION)
[](#-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")
+ }
+ }
+ }
+}