Onglet Radio sur le serveur, bouton de synchronisation sur le mobile (2.5.0)

Le serveur gagne un onglet Radio : les stations de l'annuaire, écoutables en
direct dans le navigateur ou enregistrables — jusqu'à l'arrêt ou pour une durée
choisie —, puis réécoutables depuis l'interface. Quatre enregistrements en
parallèle au plus ; si le flux tombe, il est rouvert et le fichier continue.
Les enregistrements rejoignent la bibliothèque : le mobile les reçoit.

Un redémarrage du service clôt proprement les captures en cours, et celles
restées « en cours » après un plantage sont récupérées au démarrage suivant.
La copie de flux sans blocs ICY, propre au guet, est extraite pour être
partagée.

Côté mobile, la synchronisation manuelle n'était qu'un petit lien dans l'onglet
Connexion : elle a maintenant un vrai bouton dans l'onglet Synchronisation.
Deux passages ne se chevauchent plus, et une erreur inattendue s'affiche au lieu
de fermer l'application.

Le code Kotlin n'a pas été compilé sur cette machine (pas de JDK) : il a été
relu, mais reste à construire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6pmSE168yjyf8vgV8mZx6
This commit is contained in:
tuxgyverandClaude Sonnet 5 committed 2026-09-20 22:39:59 +02:00
1 parent 7810bef8a3
commit 5b5e90247f
15 files changed
+593 -48

No files matched your search

@@ -21,6 +21,7 @@ import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.radio.RecordingSink
import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@@ -95,7 +96,21 @@ class SyncClient(
}
}
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
/** Un seul passage à la fois : la synchronisation planifiée et le bouton ne se chevauchent pas. */
private val running = Mutex()
/**
* Récupère ce qui est prêt sur le serveur.
*
* Deux passages simultanés téléchargeraient les mêmes fichiers deux fois : le second est donc
* refusé, avec un message clair plutôt qu'un doublon dans la bibliothèque.
*/
suspend fun sync(): SyncResult {
if (!running.tryLock()) return SyncResult(0, 0, "Synchronisation déjà en cours")
return try { runSync() } finally { running.unlock() }
}
private suspend fun runSync(): SyncResult = withContext(Dispatchers.IO) {
val config = settings.config.value
if (!config.isConfigured) return@withContext SyncResult(0, 0, "Serveur non configuré")
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.BuildConfig
import fr.tuxgyver.noxely.sync.SyncResult
import fr.tuxgyver.noxely.ui.sources.UpdateCard
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
@@ -84,6 +85,19 @@ fun ServerScreen(
var busy by remember { mutableStateOf(false) }
var probe by remember { mutableStateOf<String?>(null) }
// Le bouton de l'onglet Synchronisation et celui de Connexion font exactement la même chose.
val runSync: () -> Unit = {
if (!busy) {
busy = true
scope.launch {
val result = runCatching { container.sync.sync() }
.getOrElse { SyncResult(0, 0, it.message ?: "Synchronisation impossible") }
busy = false
onMessage(result.message)
}
}
}
LaunchedEffect(config.baseUrl, config.token) {
if (url.isBlank()) url = config.baseUrl
if (token.isBlank()) token = config.token
@@ -191,14 +205,7 @@ fun ServerScreen(
if (busy) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
} else if (config.isConfigured) {
TextButton(onClick = {
busy = true
scope.launch {
val result = container.sync.sync()
busy = false
onMessage(result.message)
}
}) { Text("Synchroniser") }
TextButton(onClick = runSync) { Text("Synchroniser") }
}
}
probe?.let {
@@ -249,6 +256,38 @@ fun ServerScreen(
}
}
item {
Column {
Button(
onClick = runSync,
enabled = config.isConfigured && !busy,
modifier = Modifier.fillMaxWidth(),
) {
if (busy) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(10.dp))
Text("Synchronisation en cours…")
} else {
Icon(Icons.Rounded.CloudSync, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text("Synchroniser maintenant")
}
}
if (!config.isConfigured) {
Text(
text = "Renseignez d'abord le serveur dans l'onglet Connexion.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
else -> {