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") + } + } + } +}