2 Commits
Author SHA1 Message Date
tuxgyver 5f37edb81e Retire les APK versionnés par erreur, et élargit le motif du .gitignore
Le motif portait le nom du projet (Cachette-*.apk). Le renommage l'a rendu
inopérant, et 95 Mo d'anciens APK sont entrés dans le dépôt. Le motif est
désormais *.apk : aucun renommage futur ne pourra le contourner.
2026-09-13 13:31:26 +02:00
tuxgyver 6f5adc687e Cachette devient Noxely, et gagne une page À propos (1.6.0)
Le nom change, et pas seulement à l'écran : identifiant d'application, paquet
Kotlin, dépôt et dossier d'enregistrements suivent, pour qu'il ne subsiste pas
deux noms selon l'endroit où l'on regarde.

Android traite le nouvel identifiant comme une application distincte : une
installation en 1.5.0 ne se mettra pas à jour seule et devra être remplacée.
C'est le prix d'un renommage complet, assumé plutôt que contourné. Les
enregistrements déjà faits ne sont pas perdus, étant dans le stockage partagé,
et le scan continue d'écarter l'ancien dossier pour ne pas les compter deux fois.

L'icône fournie est détournée de son fond blanc avant d'être déclinée : posée
telle quelle, le masque du système aurait redécoupé un badge qui l'est déjà et
laissé du blanc sur les bords. La couleur de fond est échantillonnée dans
l'image, non choisie au jugé.

La page À propos dit enfin ce que l'application fait, ce qu'elle garantit, avec
quoi elle est construite et qui en répond. Sa version vient du manifeste et son
journal du CHANGELOG du dépôt : ni l'un ni l'autre ne peut dériver. La licence
n'étant pas déclarée, la pastille le signale en rouge au lieu d'en inventer une.
2026-09-13 13:30:39 +02:00
17 changed files with 9 additions and 845 deletions

No files matched your search

-26
View File
@@ -5,32 +5,6 @@ Toutes les évolutions notables de Noxely sont documentées ici.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
et le versionnage [SemVer](https://semver.org/lang/fr/).
## [1.7.0] — 2026-09-13
### Ajouté
- **Guet sur l'ensemble des stations.** On nomme un morceau ou un artiste, sans désigner de
station : Noxely balaie les quarante stations les plus écoutées, quatre à la fois, et déclenche
l'enregistrement dès qu'une le diffuse. Un écran dédié sépare ce qui est attendu de ce qui a été
capté, avec le nombre de balayages et l'heure du dernier.
- **Veille en arrière-plan.** Deux mécanismes, parce qu'un seul ne suffit pas : une tâche
périodique tourne en permanence au quart d'heure — le minimum qu'autorise Android — et une
veille active, à enclencher, balaie toutes les trois minutes, écran éteint compris. Un morceau
durant trois à quatre minutes, la tâche périodique en manque forcément ; l'écran le dit au lieu
de le taire. La veille s'arrête d'elle-même quand plus rien n'est guetté.
- **Pochettes automatiques.** Les titres sans image en reçoivent une, cherchée sur leur nom via la
recherche publique d'iTunes, sans clé. Le nom est nettoyé au préalable : un enregistrement radio
traîne celui de sa station, qui brouillerait la requête.
- **Bouton d'actualisation** de l'écran Hors ligne : relance le scan de l'appareil et complète les
pochettes manquantes, par lots bornés pour ne pas marteler le service.
- L'écran de lecture affiche la provenance du morceau — nom de la playlist s'il en vient —, sa
source, sa taille et sa date.
### Corrigé
- Un morceau guetté n'était pas enregistré : le champ de saisie était vidé avant que la coroutine
n'en lise la valeur. Elle est désormais capturée avant l'effacement.
## [1.6.0] — 2026-09-13
### Changé
+1 -3
View File
@@ -6,7 +6,7 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org)
[![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose)
[![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3)
[![Version](https://img.shields.io/badge/Version-1.7.0-6B4EFF)](VERSION)
[![Version](https://img.shields.io/badge/Version-1.6.0-6B4EFF)](VERSION)
[![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche)
Noxely est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
@@ -47,8 +47,6 @@ sans la moindre connexion.
| ℹ️ **Page « À propos »** | Fonctions, garanties, pile technique, journal et auteur |
| 👀 **Titre en cours** | Ce que diffuse chaque station, lu dans son flux |
| ⏰ **Guet d'un titre** | L'enregistrement se déclenche quand le morceau voulu passe, et s'arrête à sa fin |
| 📡 **Guet sur toutes les stations** | Nommez un morceau : Noxely balaie l'annuaire jusqu'à le trouver |
| 🖼️ **Pochettes automatiques** | Les titres sans image en reçoivent une, cherchée sur leur nom |
| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation |
---
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.6.0
-5
View File
@@ -64,11 +64,6 @@
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,7 +2,6 @@ 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
@@ -11,8 +10,6 @@ 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
@@ -43,11 +40,8 @@ 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()
@@ -82,6 +76,5 @@ class NoxelyApp : Application() {
super.onCreate()
container = AppContainer(this)
UpdateCheckWorker.schedule(this)
WatchScanner.schedule(this)
}
}
@@ -1,61 +0,0 @@
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,15 +45,6 @@ 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,8 +28,6 @@ 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
}
@@ -46,7 +44,6 @@ 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()
@@ -84,10 +81,9 @@ class PlayerController(
}
/** Lance une file de lecture à partir de l'entrée choisie. */
fun play(queue: List<MediaEntry>, startIndex: Int, label: String? = null) {
fun play(queue: List<MediaEntry>, startIndex: Int) {
val controller = controller ?: return
if (queue.isEmpty()) return
queueLabel = label
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
controller.prepare()
controller.play()
@@ -112,7 +108,6 @@ class PlayerController(
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
fun stop() {
queueLabel = null
controller?.stop()
controller?.clearMediaItems()
syncState()
@@ -137,7 +132,6 @@ class PlayerController(
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
hasNext = controller.hasNextMediaItem(),
hasPrevious = controller.hasPreviousMediaItem(),
queueLabel = queueLabel,
)
}
}
@@ -1,101 +0,0 @@
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()
}
}
@@ -1,144 +0,0 @@
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))
}
}
}
@@ -1,90 +0,0 @@
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,7 +47,6 @@ 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
@@ -68,7 +67,6 @@ 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) {
@@ -90,10 +88,6 @@ 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()) {
@@ -163,16 +157,6 @@ 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,
)
}
@@ -183,7 +167,7 @@ fun NoxelyRoot(container: AppContainer) {
playlistId = entry.arguments?.getString("playlist").orEmpty(),
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onPlay = playFrom,
onPlay = play,
contentPadding = padding,
)
}
@@ -15,9 +15,7 @@ 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
@@ -61,8 +59,6 @@ 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) }
@@ -101,32 +97,7 @@ 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))
}
@@ -217,18 +188,14 @@ 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(),
) {
Column {
Row(
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(20.dp),
) {
@@ -245,31 +212,13 @@ 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,7 +36,6 @@ 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
@@ -61,10 +60,6 @@ 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(
@@ -175,12 +170,7 @@ fun NowPlayingScreen(
overflow = TextOverflow.Ellipsis,
)
entry?.let { current ->
Spacer(Modifier.size(10.dp))
TrackDetails(entry = current, queueLabel = state.queueLabel)
}
Spacer(Modifier.size(16.dp))
Spacer(Modifier.size(20.dp))
Slider(
value = scrubbing ?: if (state.durationMs > 0) {
@@ -274,36 +264,6 @@ 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, String?) -> Unit,
onPlay: (List<MediaEntry>, Int) -> 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, playlist?.name) }) {
Button(onClick = { onPlay(tracks, 0) }) {
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), playlist?.name) },
onClick = { onPlay(tracks, tracks.indexOf(entry)) },
onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) },
modifier = Modifier.weight(1f),
@@ -25,7 +25,6 @@ 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
@@ -87,7 +86,6 @@ fun RadioScreen(
currentMediaId: String?,
onBack: () -> Unit,
onMessage: (String) -> Unit,
onOpenWatches: () -> Unit,
contentPadding: PaddingValues,
) {
val context = LocalContext.current
@@ -137,11 +135,6 @@ 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")
@@ -1,271 +0,0 @@
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")
}
}
}
}