Guet sur toutes les stations, veille de fond et pochettes (1.7.0)
Guetter un morceau supposait de savoir sur quelle station il passerait. On le nomme désormais sans plus : les quarante stations les plus écoutées sont balayées quatre à la fois, et l'enregistrement part dès que l'une le diffuse. Un écran sépare ce qu'on attend de ce qu'on a capté. Reste que la tâche périodique du système ne descend pas sous le quart d'heure, quand un morceau dure trois à quatre minutes : elle ne peut qu'en manquer. Une veille active, à enclencher, balaie toutes les trois minutes en service de premier plan, et s'arrête d'elle-même une fois le guet vide. L'écran expose ce compromis plutôt que de laisser croire à une surveillance continue gratuite. Les titres sans pochette en reçoivent une, cherchée sur leur nom. Le nom est nettoyé d'abord : un enregistrement radio traîne celui de sa station, qui fausserait la recherche. Le bouton d'actualisation de l'écran Hors ligne relance le scan et complète ce qui manque. L'écran de lecture dit enfin d'où vient ce qu'on écoute : playlist, source, taille, date. Un guet ne s'enregistrait pas : le champ était vidé avant que la coroutine n'en lise la valeur. Elle est capturée avant l'effacement.
This commit is contained in:
1 parent
8d96a9fdde
commit
adc6fc0486
17 files changed
+845
-9
No files matched your search
@@ -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