Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adc6fc0486 | ||
|
|
8d96a9fdde |
No files matched your search
@@ -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é
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".radio.WatchService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -2,6 +2,7 @@ package fr.tuxgyver.noxely
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import fr.tuxgyver.noxely.library.ArtworkFinder
|
||||
import fr.tuxgyver.noxely.library.ImportedStore
|
||||
import fr.tuxgyver.noxely.library.LibraryRepository
|
||||
import fr.tuxgyver.noxely.library.LocalMediaScanner
|
||||
@@ -10,6 +11,8 @@ import fr.tuxgyver.noxely.offline.DownloadTracker
|
||||
import fr.tuxgyver.noxely.offline.OfflineModule
|
||||
import fr.tuxgyver.noxely.playback.PlayerController
|
||||
import fr.tuxgyver.noxely.radio.RadioDirectory
|
||||
import fr.tuxgyver.noxely.radio.WatchScanner
|
||||
import fr.tuxgyver.noxely.radio.WatchStore
|
||||
import fr.tuxgyver.noxely.sources.ArchiveOrgCatalog
|
||||
import fr.tuxgyver.noxely.sources.Catalog
|
||||
import fr.tuxgyver.noxely.sources.CatalogId
|
||||
@@ -40,8 +43,11 @@ class AppContainer(context: Context) {
|
||||
val imported = ImportedStore(context, scope)
|
||||
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||
val playlists = PlaylistStore(context, scope)
|
||||
val artwork = ArtworkFinder()
|
||||
|
||||
val radio = RadioDirectory()
|
||||
val watches = WatchStore(context, scope)
|
||||
val watchScanner = WatchScanner(radio, watches)
|
||||
|
||||
val urlImporter = UrlImporter()
|
||||
val rssImporter = RssImporter()
|
||||
@@ -76,5 +82,6 @@ class NoxelyApp : Application() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
UpdateCheckWorker.schedule(this)
|
||||
WatchScanner.schedule(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package fr.tuxgyver.noxely.library
|
||||
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.sources.openConnection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Cherche une pochette pour un titre, via la recherche publique d'iTunes (sans clé).
|
||||
*
|
||||
* Un enregistrement radio ou un fichier importé n'a pas d'image : celle-ci est déduite du titre
|
||||
* et de l'interprète. Rien n'est téléchargé ici — seule l'adresse est retenue, et l'affichage
|
||||
* s'en charge, ce qui évite de stocker des images pour des médias qu'on ne regardera pas.
|
||||
*/
|
||||
class ArtworkFinder {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun find(entry: MediaEntry): String? = withContext(Dispatchers.IO) {
|
||||
val term = buildString {
|
||||
append(entry.title.cleaned())
|
||||
val artist = entry.subtitle.cleaned()
|
||||
if (artist.isNotBlank() && !entry.title.contains(artist, ignoreCase = true)) {
|
||||
append(' ').append(artist)
|
||||
}
|
||||
}.trim().take(120)
|
||||
if (term.length < 3) return@withContext null
|
||||
|
||||
runCatching {
|
||||
val url = "https://itunes.apple.com/search" +
|
||||
"?term=${URLEncoder.encode(term, "UTF-8")}&entity=song&limit=1"
|
||||
val connection = openConnection(url, readTimeoutMs = 15_000)
|
||||
val body = try {
|
||||
if (connection.responseCode !in 200..299) return@runCatching null
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
val first = (json.parseToJsonElement(body).jsonObject["results"] as? JsonArray)
|
||||
?.firstOrNull() as? JsonObject ?: return@runCatching null
|
||||
|
||||
// La vignette est servie en 100 px ; l'adresse accepte une taille supérieure.
|
||||
first["artworkUrl100"]?.jsonPrimitive?.content?.replace("100x100", "600x600")
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Les enregistrements radio traînent le nom de la station : il brouille la recherche. */
|
||||
private fun String.cleaned(): String = this
|
||||
.substringBefore(" - Classic Vinyl on ")
|
||||
.replace(Regex("\\s+on\\s+\\S+\\.(com|fr|net|org)", RegexOption.IGNORE_CASE), "")
|
||||
.replace(Regex("^\\d{1,3}\\s*[-.]\\s*"), "")
|
||||
.replace(Regex("\\.(mp3|aac|ogg|m4a|flac)$", RegexOption.IGNORE_CASE), "")
|
||||
.trim()
|
||||
}
|
||||
@@ -45,6 +45,15 @@ class ImportedStore(context: Context, private val scope: CoroutineScope) {
|
||||
fresh.size
|
||||
}
|
||||
|
||||
/** Renseigne la pochette trouvée pour une entrée, sans toucher au reste. */
|
||||
suspend fun setArtwork(id: String, artworkUri: String) = mutex.withLock {
|
||||
val merged = _entries.value.map { if (it.id == id) it.copy(artworkUri = artworkUri) else it }
|
||||
if (merged != _entries.value) {
|
||||
_entries.value = merged
|
||||
write(merged)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun remove(id: String) = mutex.withLock {
|
||||
val merged = _entries.value.filterNot { it.id == id }
|
||||
if (merged.size != _entries.value.size) {
|
||||
|
||||
@@ -28,6 +28,8 @@ data class PlayerUiState(
|
||||
val durationMs: Long = 0L,
|
||||
val hasNext: Boolean = false,
|
||||
val hasPrevious: Boolean = false,
|
||||
/** D'où vient la file en cours : nom de playlist, d'album, de station. */
|
||||
val queueLabel: String? = null,
|
||||
) {
|
||||
val isIdle: Boolean get() = mediaId == null
|
||||
}
|
||||
@@ -44,6 +46,7 @@ class PlayerController(
|
||||
private val resolve: (String) -> MediaEntry?,
|
||||
) {
|
||||
private var controller: MediaController? = null
|
||||
private var queueLabel: String? = null
|
||||
|
||||
private val _state = MutableStateFlow(PlayerUiState())
|
||||
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
|
||||
@@ -81,9 +84,10 @@ class PlayerController(
|
||||
}
|
||||
|
||||
/** Lance une file de lecture à partir de l'entrée choisie. */
|
||||
fun play(queue: List<MediaEntry>, startIndex: Int) {
|
||||
fun play(queue: List<MediaEntry>, 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<WatchWorker>(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()
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<Watch>>(emptyList())
|
||||
val watches: StateFlow<List<Watch>> = _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<String>) = 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<Watch> = _watches.value.filterNot { it.isFound }
|
||||
|
||||
private suspend fun mutate(transform: (List<Watch>) -> List<Watch>) = mutex.withLock {
|
||||
val updated = transform(_watches.value)
|
||||
if (updated != _watches.value) {
|
||||
_watches.value = updated
|
||||
write(updated)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun read(): List<Watch> = withContext(Dispatchers.IO) {
|
||||
if (!file.exists()) return@withContext emptyList()
|
||||
runCatching { json.decodeFromString<List<Watch>>(file.readText()) }.getOrElse { emptyList() }
|
||||
}
|
||||
|
||||
private suspend fun write(watches: List<Watch>) = withContext(Dispatchers.IO) {
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(json.encodeToString(watches))
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
@@ -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<MediaEntry>, 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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<MediaEntry?>(null) }
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
var refreshNote by remember { mutableStateOf<String?>(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(
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -46,7 +46,7 @@ fun PlaylistDetailScreen(
|
||||
playlistId: String,
|
||||
currentMediaId: String?,
|
||||
onBack: () -> Unit,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
onPlay: (List<MediaEntry>, 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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user