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/)
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
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
|
## [1.6.0] — 2026-09-13
|
||||||
|
|
||||||
### Changé
|
### Changé
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
[](https://developer.android.com/jetpack/compose)
|
[](https://developer.android.com/jetpack/compose)
|
||||||
[](https://developer.android.com/media/media3)
|
[](https://developer.android.com/media/media3)
|
||||||
[](VERSION)
|
[](VERSION)
|
||||||
[](#-comment-ça-marche)
|
[](#-comment-ça-marche)
|
||||||
|
|
||||||
Noxely est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
|
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 |
|
| ℹ️ **Page « À propos »** | Fonctions, garanties, pile technique, journal et auteur |
|
||||||
| 👀 **Titre en cours** | Ce que diffuse chaque station, lu dans son flux |
|
| 👀 **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 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 |
|
| 🆕 **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:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".radio.WatchService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package fr.tuxgyver.noxely
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import fr.tuxgyver.noxely.library.ArtworkFinder
|
||||||
import fr.tuxgyver.noxely.library.ImportedStore
|
import fr.tuxgyver.noxely.library.ImportedStore
|
||||||
import fr.tuxgyver.noxely.library.LibraryRepository
|
import fr.tuxgyver.noxely.library.LibraryRepository
|
||||||
import fr.tuxgyver.noxely.library.LocalMediaScanner
|
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.offline.OfflineModule
|
||||||
import fr.tuxgyver.noxely.playback.PlayerController
|
import fr.tuxgyver.noxely.playback.PlayerController
|
||||||
import fr.tuxgyver.noxely.radio.RadioDirectory
|
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.ArchiveOrgCatalog
|
||||||
import fr.tuxgyver.noxely.sources.Catalog
|
import fr.tuxgyver.noxely.sources.Catalog
|
||||||
import fr.tuxgyver.noxely.sources.CatalogId
|
import fr.tuxgyver.noxely.sources.CatalogId
|
||||||
@@ -40,8 +43,11 @@ class AppContainer(context: Context) {
|
|||||||
val imported = ImportedStore(context, scope)
|
val imported = ImportedStore(context, scope)
|
||||||
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||||
val playlists = PlaylistStore(context, scope)
|
val playlists = PlaylistStore(context, scope)
|
||||||
|
val artwork = ArtworkFinder()
|
||||||
|
|
||||||
val radio = RadioDirectory()
|
val radio = RadioDirectory()
|
||||||
|
val watches = WatchStore(context, scope)
|
||||||
|
val watchScanner = WatchScanner(radio, watches)
|
||||||
|
|
||||||
val urlImporter = UrlImporter()
|
val urlImporter = UrlImporter()
|
||||||
val rssImporter = RssImporter()
|
val rssImporter = RssImporter()
|
||||||
@@ -76,5 +82,6 @@ class NoxelyApp : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = AppContainer(this)
|
container = AppContainer(this)
|
||||||
UpdateCheckWorker.schedule(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
|
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 {
|
suspend fun remove(id: String) = mutex.withLock {
|
||||||
val merged = _entries.value.filterNot { it.id == id }
|
val merged = _entries.value.filterNot { it.id == id }
|
||||||
if (merged.size != _entries.value.size) {
|
if (merged.size != _entries.value.size) {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ data class PlayerUiState(
|
|||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
val hasNext: Boolean = false,
|
val hasNext: Boolean = false,
|
||||||
val hasPrevious: 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
|
val isIdle: Boolean get() = mediaId == null
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ class PlayerController(
|
|||||||
private val resolve: (String) -> MediaEntry?,
|
private val resolve: (String) -> MediaEntry?,
|
||||||
) {
|
) {
|
||||||
private var controller: MediaController? = null
|
private var controller: MediaController? = null
|
||||||
|
private var queueLabel: String? = null
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PlayerUiState())
|
private val _state = MutableStateFlow(PlayerUiState())
|
||||||
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
|
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
|
||||||
@@ -81,9 +84,10 @@ class PlayerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Lance une file de lecture à partir de l'entrée choisie. */
|
/** 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
|
val controller = controller ?: return
|
||||||
if (queue.isEmpty()) return
|
if (queue.isEmpty()) return
|
||||||
|
queueLabel = label
|
||||||
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
|
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
|
||||||
controller.prepare()
|
controller.prepare()
|
||||||
controller.play()
|
controller.play()
|
||||||
@@ -108,6 +112,7 @@ class PlayerController(
|
|||||||
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
|
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
queueLabel = null
|
||||||
controller?.stop()
|
controller?.stop()
|
||||||
controller?.clearMediaItems()
|
controller?.clearMediaItems()
|
||||||
syncState()
|
syncState()
|
||||||
@@ -132,6 +137,7 @@ class PlayerController(
|
|||||||
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
|
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
|
||||||
hasNext = controller.hasNextMediaItem(),
|
hasNext = controller.hasNextMediaItem(),
|
||||||
hasPrevious = controller.hasPreviousMediaItem(),
|
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.offline.DownloadsScreen
|
||||||
import fr.tuxgyver.noxely.ui.player.MiniPlayer
|
import fr.tuxgyver.noxely.ui.player.MiniPlayer
|
||||||
import fr.tuxgyver.noxely.ui.radio.RadioScreen
|
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.AddToPlaylistSheet
|
||||||
import fr.tuxgyver.noxely.ui.playlists.PlaylistDetailScreen
|
import fr.tuxgyver.noxely.ui.playlists.PlaylistDetailScreen
|
||||||
import fr.tuxgyver.noxely.ui.playlists.PlaylistsScreen
|
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 PLAYLIST_ROUTE = "playlist/{playlist}"
|
||||||
private const val RADIO_ROUTE = "radio"
|
private const val RADIO_ROUTE = "radio"
|
||||||
private const val ABOUT_ROUTE = "about"
|
private const val ABOUT_ROUTE = "about"
|
||||||
|
private const val WATCHES_ROUTE = "watches"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NoxelyRoot(container: AppContainer) {
|
fun NoxelyRoot(container: AppContainer) {
|
||||||
@@ -88,6 +90,10 @@ fun NoxelyRoot(container: AppContainer) {
|
|||||||
container.player.play(queue, index)
|
container.player.play(queue, index)
|
||||||
expanded = true
|
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) } }
|
val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
@@ -157,6 +163,16 @@ fun NoxelyRoot(container: AppContainer) {
|
|||||||
currentMediaId = playerState.mediaId,
|
currentMediaId = playerState.mediaId,
|
||||||
onBack = navController::popBackStack,
|
onBack = navController::popBackStack,
|
||||||
onMessage = notify,
|
onMessage = notify,
|
||||||
|
onOpenWatches = { navController.navigate(WATCHES_ROUTE) },
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(WATCHES_ROUTE) {
|
||||||
|
WatchListScreen(
|
||||||
|
container = container,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
|
onMessage = notify,
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -167,7 +183,7 @@ fun NoxelyRoot(container: AppContainer) {
|
|||||||
playlistId = entry.arguments?.getString("playlist").orEmpty(),
|
playlistId = entry.arguments?.getString("playlist").orEmpty(),
|
||||||
currentMediaId = playerState.mediaId,
|
currentMediaId = playerState.mediaId,
|
||||||
onBack = navController::popBackStack,
|
onBack = navController::popBackStack,
|
||||||
onPlay = play,
|
onPlay = playFrom,
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.CloudOff
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||||
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
@@ -59,6 +61,8 @@ fun DownloadsScreen(
|
|||||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||||
var confirmClear by remember { mutableStateOf(false) }
|
var confirmClear by remember { mutableStateOf(false) }
|
||||||
var pendingRecordingDelete by remember { mutableStateOf<MediaEntry?>(null) }
|
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 context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var cacheSize by remember { mutableLongStateOf(0L) }
|
var cacheSize by remember { mutableLongStateOf(0L) }
|
||||||
@@ -97,7 +101,32 @@ fun DownloadsScreen(
|
|||||||
cacheSize = cacheSize,
|
cacheSize = cacheSize,
|
||||||
completed = completed,
|
completed = completed,
|
||||||
running = running,
|
running = running,
|
||||||
|
refreshing = refreshing,
|
||||||
|
note = refreshNote,
|
||||||
onClear = { confirmClear = true },
|
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))
|
Spacer(Modifier.size(14.dp))
|
||||||
}
|
}
|
||||||
@@ -188,13 +217,17 @@ private fun StorageCard(
|
|||||||
cacheSize: Long,
|
cacheSize: Long,
|
||||||
completed: Int,
|
completed: Int,
|
||||||
running: Int,
|
running: Int,
|
||||||
|
refreshing: Boolean,
|
||||||
|
note: String?,
|
||||||
onClear: () -> Unit,
|
onClear: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(20.dp),
|
modifier = Modifier.padding(20.dp),
|
||||||
@@ -212,12 +245,30 @@ private fun StorageCard(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
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) {
|
if (completed > 0 || running > 0) {
|
||||||
TextButton(onClick = onClear) { Text("Tout libérer") }
|
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
|
@Composable
|
||||||
private fun EmptyOffline() {
|
private fun EmptyOffline() {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.playback.PlayerUiState
|
||||||
import fr.tuxgyver.noxely.ui.components.Artwork
|
import fr.tuxgyver.noxely.ui.components.Artwork
|
||||||
import fr.tuxgyver.noxely.ui.components.formatDuration
|
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
|
@Composable
|
||||||
fun NowPlayingScreen(
|
fun NowPlayingScreen(
|
||||||
@@ -170,7 +175,12 @@ fun NowPlayingScreen(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
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(
|
Slider(
|
||||||
value = scrubbing ?: if (state.durationMs > 0) {
|
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
|
@Composable
|
||||||
private fun OfflineToggle(
|
private fun OfflineToggle(
|
||||||
entry: MediaEntry?,
|
entry: MediaEntry?,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fun PlaylistDetailScreen(
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
currentMediaId: String?,
|
currentMediaId: String?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
onPlay: (List<MediaEntry>, Int, String?) -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
) {
|
) {
|
||||||
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
||||||
@@ -81,7 +81,7 @@ fun PlaylistDetailScreen(
|
|||||||
|
|
||||||
if (tracks.isNotEmpty()) {
|
if (tracks.isNotEmpty()) {
|
||||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
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))
|
Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text("Tout lire")
|
Text("Tout lire")
|
||||||
@@ -115,7 +115,7 @@ fun PlaylistDetailScreen(
|
|||||||
entry = entry,
|
entry = entry,
|
||||||
offline = offlineStates[entry.id],
|
offline = offlineStates[entry.id],
|
||||||
isCurrent = entry.id == currentMediaId,
|
isCurrent = entry.id == currentMediaId,
|
||||||
onClick = { onPlay(tracks, tracks.indexOf(entry)) },
|
onClick = { onPlay(tracks, tracks.indexOf(entry), playlist?.name) },
|
||||||
onDownload = { container.downloads.download(entry) },
|
onDownload = { container.downloads.download(entry) },
|
||||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
modifier = Modifier.weight(1f),
|
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.Alarm
|
||||||
import androidx.compose.material.icons.rounded.GraphicEq
|
import androidx.compose.material.icons.rounded.GraphicEq
|
||||||
import androidx.compose.material.icons.rounded.PlayCircleOutline
|
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.Search
|
||||||
import androidx.compose.material.icons.rounded.Stop
|
import androidx.compose.material.icons.rounded.Stop
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -86,6 +87,7 @@ fun RadioScreen(
|
|||||||
currentMediaId: String?,
|
currentMediaId: String?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onMessage: (String) -> Unit,
|
onMessage: (String) -> Unit,
|
||||||
|
onOpenWatches: () -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -135,6 +137,11 @@ fun RadioScreen(
|
|||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Radio") },
|
title = { Text("Radio") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onOpenWatches) {
|
||||||
|
Icon(Icons.Rounded.Radar, contentDescription = "Morceaux guettés")
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
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