Noxely se sépare en client et serveur (2.0.0)
Le téléphone balayait quarante radios toutes les trois minutes, cherchait dans quatre catalogues et enregistrait des directs. Tout cela le tenait éveillé pour un travail qui n'a aucune raison de se faire sur batterie. Un serveur reprend la collecte : recherche, guet, enregistrement, file de téléchargement, piloté depuis un navigateur. Le mobile demande ce qui a changé depuis son curseur, télécharge et se rendort — toutes les six heures, en Wi-Fi non facturé. Vingt fichiers quittent l'application au passage. Le balayage devient continu plutôt qu'espacé de trois minutes : une sonde ne lit que le premier bloc de métadonnées avant de refermer, ce qui rend la permanence tenable quand garder les flux ouverts coûterait plusieurs mégabits par seconde. Un morceau guetté a été capté en moins de vingt-cinq secondes. La recherche interroge les quatre catalogues à la fois et fond leurs résultats, les plus légers d'abord. Quatre défauts corrigés en chemin, tous trouvés en essayant plutôt qu'en relisant : un jeton régénéré à chaque démarrage, qui aurait obligé à reconfigurer le mobile ; un 403 de Wikimedia dû à un agent trop générique, que la fusion des sources masquait ; un dossier de données par défaut inaccessible au compte qui lance le serveur ; et une lecture de métadonnées qui n'itérait rien, faute d'être asynchrone.
This commit is contained in:
1 parent
adc6fc0486
commit
ed1d40826b
48 files changed
+2081
-3619
No files matched your search
@@ -59,16 +59,6 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".radio.RecordingService"
|
||||
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"
|
||||
|
||||
@@ -10,17 +10,9 @@ import fr.tuxgyver.noxely.library.PlaylistStore
|
||||
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
|
||||
import fr.tuxgyver.noxely.sources.CatalogItem
|
||||
import fr.tuxgyver.noxely.sources.WikimediaCatalog
|
||||
import fr.tuxgyver.noxely.sources.LibriVoxCatalog
|
||||
import fr.tuxgyver.noxely.sources.RssImporter
|
||||
import fr.tuxgyver.noxely.sources.UrlImporter
|
||||
import fr.tuxgyver.noxely.sync.ServerSettings
|
||||
import fr.tuxgyver.noxely.sync.SyncClient
|
||||
import fr.tuxgyver.noxely.sync.SyncWorker
|
||||
import fr.tuxgyver.noxely.update.UpdateCheckWorker
|
||||
import fr.tuxgyver.noxely.update.UpdateRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -45,28 +37,12 @@ class AppContainer(context: Context) {
|
||||
val playlists = PlaylistStore(context, scope)
|
||||
val artwork = ArtworkFinder()
|
||||
|
||||
val radio = RadioDirectory()
|
||||
val watches = WatchStore(context, scope)
|
||||
val watchScanner = WatchScanner(radio, watches)
|
||||
val serverSettings = ServerSettings(context, scope)
|
||||
val sync = SyncClient(context, serverSettings, imported)
|
||||
|
||||
|
||||
val urlImporter = UrlImporter()
|
||||
val rssImporter = RssImporter()
|
||||
|
||||
val catalogs: Map<CatalogId, Catalog> = listOf(
|
||||
ArchiveOrgCatalog(),
|
||||
ArchiveOrgCatalog(
|
||||
id = CatalogId.UNDERGROUND,
|
||||
collections = listOf("netlabels", "freemusicarchive"),
|
||||
),
|
||||
LibriVoxCatalog(rssImporter),
|
||||
WikimediaCatalog(),
|
||||
).associateBy { it.id }
|
||||
|
||||
/**
|
||||
* Résultat de catalogue en cours de consultation.
|
||||
* Un identifiant de route ne suffirait pas à le reconstituer sans un appel réseau de plus.
|
||||
*/
|
||||
var openedCatalogItem: CatalogItem? = null
|
||||
|
||||
val player = PlayerController(context, scope) { id -> library.entry(id) }
|
||||
|
||||
@@ -82,6 +58,6 @@ class NoxelyApp : Application() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
UpdateCheckWorker.schedule(this)
|
||||
WatchScanner.schedule(this)
|
||||
SyncWorker.schedule(this)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ enum class SourceKind(val label: String) {
|
||||
URL("Lien direct"),
|
||||
PODCAST("Podcast"),
|
||||
RECORDING("Enregistrement"),
|
||||
SERVER("Serveur"),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,5 +34,5 @@ data class MediaEntry(
|
||||
val sizeBytes: Long? = null,
|
||||
) {
|
||||
/** Local ou enregistré : le fichier est déjà sur l'appareil, il n'y a rien à télécharger. */
|
||||
val isRemote: Boolean get() = source != SourceKind.LOCAL && source != SourceKind.RECORDING
|
||||
val isRemote: Boolean get() = source == SourceKind.URL || source == SourceKind.PODCAST
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package fr.tuxgyver.noxely.radio
|
||||
|
||||
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.JsonPrimitive
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Annuaire des stations, via l'API communautaire Radio Browser (sans clé).
|
||||
*
|
||||
* Les flux HLS sont écartés : ils se lisent, mais ne s'enregistrent pas en copiant les octets,
|
||||
* et proposer un bouton qui échouerait vaudrait moins que ne pas le proposer.
|
||||
*/
|
||||
class RadioDirectory {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** Les stations les plus écoutées, éventuellement restreintes à un genre. */
|
||||
suspend fun popular(genre: String? = null): Result<List<RadioStation>> =
|
||||
query(buildString {
|
||||
append("?limit=60&hidebroken=true&order=clickcount&reverse=true")
|
||||
genre?.let { append("&tag=${URLEncoder.encode(it, "UTF-8")}") }
|
||||
})
|
||||
|
||||
suspend fun search(name: String): Result<List<RadioStation>> =
|
||||
query("?limit=60&hidebroken=true&order=clickcount&reverse=true" +
|
||||
"&name=${URLEncoder.encode(name.trim(), "UTF-8")}")
|
||||
|
||||
/**
|
||||
* Le morceau diffusé à l'instant par une station.
|
||||
*
|
||||
* Aucun annuaire ne l'expose : il faut ouvrir le flux, lire le premier bloc de métadonnées
|
||||
* ICY, puis refermer. C'est une connexion par station — d'où l'appel par petits paquets et
|
||||
* la mise en cache par l'appelant plutôt qu'un balayage systématique.
|
||||
*/
|
||||
suspend fun nowPlaying(streamUrl: String): String? = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val connection = openConnection(streamUrl, readTimeoutMs = 8_000).apply {
|
||||
setRequestProperty("Icy-MetaData", "1")
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) return@runCatching null
|
||||
val interval = connection.getHeaderFieldInt("icy-metaint", 0)
|
||||
if (interval <= 0) return@runCatching null
|
||||
|
||||
connection.inputStream.use { input ->
|
||||
val skip = ByteArray(interval)
|
||||
var filled = 0
|
||||
while (filled < interval) {
|
||||
val read = input.read(skip, filled, interval - filled)
|
||||
if (read < 0) return@runCatching null
|
||||
filled += read
|
||||
}
|
||||
val length = input.read().takeIf { it >= 0 }?.times(16) ?: return@runCatching null
|
||||
if (length == 0) return@runCatching null
|
||||
val meta = ByteArray(length)
|
||||
filled = 0
|
||||
while (filled < length) {
|
||||
val read = input.read(meta, filled, length - filled)
|
||||
if (read < 0) return@runCatching null
|
||||
filled += read
|
||||
}
|
||||
STREAM_TITLE.find(String(meta, Charsets.UTF_8))
|
||||
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun query(suffix: String): Result<List<RadioStation>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000)
|
||||
val body = try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("L'annuaire a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
(json.parseToJsonElement(body) as? JsonArray).orEmpty()
|
||||
.mapNotNull { it as? JsonObject }
|
||||
.filter { it.number("hls") == 0L }
|
||||
.mapNotNull { station ->
|
||||
val url = station.string("url_resolved")?.takeIf { it.startsWith("http") }
|
||||
?: return@mapNotNull null
|
||||
RadioStation(
|
||||
id = station.string("stationuuid") ?: url,
|
||||
name = station.string("name")?.trim() ?: "Station",
|
||||
streamUrl = url,
|
||||
codec = station.string("codec").orEmpty(),
|
||||
bitrate = station.number("bitrate")?.toInt() ?: 0,
|
||||
country = station.string("country").orEmpty(),
|
||||
tags = station.string("tags").orEmpty(),
|
||||
faviconUrl = station.string("favicon")?.takeIf { it.startsWith("http") },
|
||||
)
|
||||
}
|
||||
.distinctBy { it.streamUrl }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BASE = "https://de1.api.radio-browser.info/json/stations/search"
|
||||
|
||||
/** Le titre court jusqu'à la fin du champ ICY : les apostrophes y sont fréquentes. */
|
||||
val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
|
||||
|
||||
/** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */
|
||||
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
|
||||
|
||||
fun JsonObject.string(key: String): String? =
|
||||
(this[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
|
||||
|
||||
fun JsonObject.number(key: String): Long? =
|
||||
(this[key] as? JsonPrimitive)?.content?.substringBefore('.')?.toLongOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Genres offerts en filtre rapide. */
|
||||
val RADIO_GENRES = listOf("pop", "rock", "jazz", "classical", "electronic", "news", "chanson")
|
||||
@@ -1,20 +0,0 @@
|
||||
package fr.tuxgyver.noxely.radio
|
||||
|
||||
/** Une station de radio et son flux direct. */
|
||||
data class RadioStation(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val streamUrl: String,
|
||||
val codec: String,
|
||||
val bitrate: Int,
|
||||
val country: String,
|
||||
val tags: String,
|
||||
val faviconUrl: String?,
|
||||
) {
|
||||
val subtitle: String
|
||||
get() = listOfNotNull(
|
||||
country.takeIf { it.isNotBlank() },
|
||||
tags.split(',').firstOrNull()?.trim()?.takeIf { it.isNotBlank() }?.replaceFirstChar(Char::uppercase),
|
||||
bitrate.takeIf { it > 0 }?.let { "$it kbps" },
|
||||
).joinToString(" · ")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package fr.tuxgyver.noxely.radio
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class RecordingStatus(
|
||||
val station: RadioStation? = null,
|
||||
val startedAt: Long = 0L,
|
||||
val bytes: Long = 0L,
|
||||
/** Titre annoncé par le flux, quand la station en diffuse un. */
|
||||
val currentTitle: String? = null,
|
||||
/** Titre attendu avant de commencer à écrire, si l'enregistrement a été programmé. */
|
||||
val waitingFor: String? = null,
|
||||
) {
|
||||
val isRecording: Boolean get() = station != null
|
||||
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* État de l'enregistrement en cours.
|
||||
*
|
||||
* Seul [RecordingService] y écrit ; l'interface ne fait que l'observer. Un singleton se justifie
|
||||
* ici parce que l'enregistrement est unique par nature : on n'enregistre qu'une station à la fois.
|
||||
*/
|
||||
object Recorder {
|
||||
private val _status = MutableStateFlow(RecordingStatus())
|
||||
val status: StateFlow<RecordingStatus> = _status.asStateFlow()
|
||||
|
||||
internal fun started(station: RadioStation, waitingFor: String? = null) {
|
||||
_status.value = RecordingStatus(station, System.currentTimeMillis(), 0L, waitingFor = waitingFor)
|
||||
}
|
||||
|
||||
/** Le morceau attendu commence : l'écriture démarre maintenant. */
|
||||
internal fun armed(title: String) {
|
||||
_status.value = _status.value.copy(
|
||||
waitingFor = null,
|
||||
currentTitle = title,
|
||||
startedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun progress(bytes: Long) {
|
||||
_status.value = _status.value.copy(bytes = bytes)
|
||||
}
|
||||
|
||||
internal fun title(title: String) {
|
||||
_status.value = _status.value.copy(currentTitle = title)
|
||||
}
|
||||
|
||||
internal fun stopped() {
|
||||
_status.value = RecordingStatus()
|
||||
}
|
||||
}
|
||||
@@ -1,380 +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.NoxelyApp
|
||||
import fr.tuxgyver.noxely.MainActivity
|
||||
import fr.tuxgyver.noxely.R
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
import fr.tuxgyver.noxely.sources.openConnection
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Enregistre un flux radio en copiant ses octets dans un fichier.
|
||||
*
|
||||
* Un direct n'a pas de fin : `DownloadManager`, qui attend une taille et une complétion, ne peut
|
||||
* pas s'en charger. L'enregistrement tourne donc en service de premier plan jusqu'à ce que
|
||||
* l'utilisateur l'arrête, et le fichier obtenu rejoint la bibliothèque comme média local.
|
||||
*/
|
||||
class RecordingService : Service() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var job: Job? = null
|
||||
private var sink: RecordingSink? = null
|
||||
private var sinkTitle: String = ""
|
||||
|
||||
private var station: RadioStation? = null
|
||||
private var firstTitle: String? = null
|
||||
private var trigger: String? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
finish()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
ACTION_START -> {
|
||||
val incoming = intent.toStation() ?: run {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (job?.isActive == true) return START_STICKY
|
||||
trigger = intent.getStringExtra(EXTRA_TRIGGER)?.takeIf { it.isNotBlank() }
|
||||
begin(incoming)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun begin(incoming: RadioStation) {
|
||||
station = incoming
|
||||
firstTitle = null
|
||||
Recorder.started(incoming, trigger)
|
||||
startForegroundCompat(notification(incoming, 0L))
|
||||
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.FRANCE).format(Date())
|
||||
val extension = incoming.extension()
|
||||
sinkTitle = "${incoming.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}"
|
||||
val output = RecordingSink(
|
||||
context = this,
|
||||
fileName = "${incoming.name.sanitized()}-$stamp.$extension",
|
||||
mimeType = if (extension == "aac") "audio/aac" else "audio/mpeg",
|
||||
)
|
||||
sink = output
|
||||
|
||||
job = scope.launch {
|
||||
runCatching {
|
||||
// « Icy-MetaData: 1 » demande à la station d'intercaler le titre en cours dans le
|
||||
// flux. Ces octets ne sont pas de l'audio : ils doivent être retirés avant écriture,
|
||||
// sinon le fichier obtenu est corrompu.
|
||||
val connection = openConnection(incoming.streamUrl, readTimeoutMs = 30_000).apply {
|
||||
setRequestProperty("Icy-MetaData", "1")
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Flux indisponible (${connection.responseCode})")
|
||||
}
|
||||
val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0)
|
||||
connection.inputStream.use { input ->
|
||||
output.open().use { stream ->
|
||||
if (metaInterval > 0) {
|
||||
copyWithTitles(input, stream, metaInterval, incoming)
|
||||
} else if (trigger != null) {
|
||||
// Sans métadonnées, impossible de savoir quand le morceau passe.
|
||||
throw IllegalStateException(
|
||||
"${incoming.name} n'annonce pas ses titres : l'attente est impossible"
|
||||
)
|
||||
} else {
|
||||
copyPlain(input, stream, incoming)
|
||||
}
|
||||
stream.flush()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
// Fin du flux ou coupure réseau : ce qui a été capté reste valable.
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/** Flux sans métadonnées : simple recopie. */
|
||||
private suspend fun copyPlain(
|
||||
input: java.io.InputStream,
|
||||
output: java.io.OutputStream,
|
||||
station: RadioStation,
|
||||
) {
|
||||
val buffer = ByteArray(32 * 1024)
|
||||
var total = 0L
|
||||
var lastNotified = 0L
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
output.write(buffer, 0, read)
|
||||
total += read
|
||||
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
|
||||
lastNotified = total
|
||||
Recorder.progress(total)
|
||||
notifyProgress(station, total)
|
||||
}
|
||||
}
|
||||
Recorder.progress(total)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux ICY : `interval` octets d'audio, puis un bloc de métadonnées à retirer.
|
||||
* Le premier titre rencontré nomme l'enregistrement ; les suivants alimentent la notification.
|
||||
*/
|
||||
private suspend fun copyWithTitles(
|
||||
input: java.io.InputStream,
|
||||
output: java.io.OutputStream,
|
||||
interval: Int,
|
||||
station: RadioStation,
|
||||
) {
|
||||
val wanted = trigger
|
||||
val audio = ByteArray(interval)
|
||||
var total = 0L
|
||||
var lastNotified = 0L
|
||||
// Sans titre attendu, on écrit dès la première seconde ; sinon on patiente.
|
||||
var writing = wanted == null
|
||||
var capturedTitle: String? = null
|
||||
|
||||
while (currentCoroutineContext().isActive) {
|
||||
if (!input.readFully(audio, interval)) break
|
||||
if (writing) {
|
||||
output.write(audio)
|
||||
total += interval
|
||||
}
|
||||
|
||||
val lengthByte = input.read()
|
||||
if (lengthByte < 0) break
|
||||
val metaLength = lengthByte * 16
|
||||
if (metaLength > 0) {
|
||||
val meta = ByteArray(metaLength)
|
||||
if (!input.readFully(meta, metaLength)) break
|
||||
val announced = STREAM_TITLE
|
||||
.find(String(meta, Charsets.UTF_8))
|
||||
?.groupValues?.get(1)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
if (announced != null) {
|
||||
Recorder.title(announced)
|
||||
when {
|
||||
wanted == null -> if (firstTitle == null) firstTitle = announced
|
||||
|
||||
!writing && announced.contains(wanted, ignoreCase = true) -> {
|
||||
// Le morceau attendu commence : on enregistre à partir d'ici.
|
||||
writing = true
|
||||
capturedTitle = announced
|
||||
firstTitle = announced
|
||||
Recorder.armed(announced)
|
||||
}
|
||||
|
||||
writing && capturedTitle != null && announced != capturedTitle -> {
|
||||
// Le morceau est terminé : on s'arrête sans attendre le suivant.
|
||||
break
|
||||
}
|
||||
}
|
||||
notifyProgress(station, total)
|
||||
}
|
||||
}
|
||||
|
||||
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
|
||||
lastNotified = total
|
||||
Recorder.progress(total)
|
||||
notifyProgress(station, total)
|
||||
}
|
||||
}
|
||||
Recorder.progress(total)
|
||||
}
|
||||
|
||||
/** Lit exactement [count] octets, ou renvoie false si le flux s'interrompt avant. */
|
||||
private fun java.io.InputStream.readFully(target: ByteArray, count: Int): Boolean {
|
||||
var filled = 0
|
||||
while (filled < count) {
|
||||
val read = read(target, filled, count - filled)
|
||||
if (read < 0) return false
|
||||
filled += read
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Clôt l'enregistrement et verse le fichier obtenu dans la bibliothèque. */
|
||||
private fun finish() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
|
||||
val output = sink
|
||||
val recorded = station
|
||||
val captured = firstTitle
|
||||
sink = null
|
||||
station = null
|
||||
trigger = null
|
||||
|
||||
if (output != null && recorded != null) {
|
||||
val elapsed = Recorder.status.value.elapsedMs
|
||||
// Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant.
|
||||
val title = captured ?: sinkTitle
|
||||
val published = output.finish(title, elapsed)
|
||||
if (published != null) {
|
||||
val (uri, size) = published
|
||||
val container = (application as NoxelyApp).container
|
||||
val entry = MediaEntry(
|
||||
id = "rec:${uri.hashCode()}",
|
||||
source = SourceKind.RECORDING,
|
||||
kind = MediaKind.AUDIO,
|
||||
title = title,
|
||||
subtitle = if (captured != null) recorded.name else "Enregistrement radio",
|
||||
uri = uri,
|
||||
artworkUri = recorded.faviconUrl,
|
||||
durationMs = elapsed,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
sizeBytes = size,
|
||||
)
|
||||
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) }
|
||||
}
|
||||
}
|
||||
|
||||
firstTitle = null
|
||||
Recorder.stopped()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
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 notifyProgress(station: RadioStation, bytes: Long) {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, notification(station, bytes))
|
||||
}
|
||||
|
||||
private fun notification(station: RadioStation, bytes: Long): Notification {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Enregistrement 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, RecordingService::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(
|
||||
if (Recorder.status.value.waitingFor != null) "En attente sur ${station.name}"
|
||||
else "Enregistrement de ${station.name}"
|
||||
)
|
||||
.setContentText(
|
||||
Recorder.status.value.currentTitle
|
||||
?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…"
|
||||
)
|
||||
.setContentIntent(open)
|
||||
.addAction(0, "Arrêter", stop)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun Intent.toStation(): RadioStation? {
|
||||
val url = getStringExtra(EXTRA_URL) ?: return null
|
||||
return RadioStation(
|
||||
id = getStringExtra(EXTRA_ID).orEmpty(),
|
||||
name = getStringExtra(EXTRA_NAME) ?: "Radio",
|
||||
streamUrl = url,
|
||||
codec = getStringExtra(EXTRA_CODEC).orEmpty(),
|
||||
bitrate = getIntExtra(EXTRA_BITRATE, 0),
|
||||
country = "",
|
||||
tags = "",
|
||||
faviconUrl = getStringExtra(EXTRA_FAVICON),
|
||||
)
|
||||
}
|
||||
|
||||
private fun RadioStation.extension(): String =
|
||||
if (codec.equals("AAC", ignoreCase = true) || codec.startsWith("AAC", true)) "aac" else "mp3"
|
||||
|
||||
private fun String.sanitized(): String =
|
||||
replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(' ', '_').take(40).ifBlank { "radio" }
|
||||
|
||||
companion object {
|
||||
private const val ACTION_START = "fr.tuxgyver.noxely.RECORD_START"
|
||||
private const val ACTION_STOP = "fr.tuxgyver.noxely.RECORD_STOP"
|
||||
private const val EXTRA_ID = "id"
|
||||
private const val EXTRA_NAME = "name"
|
||||
private const val EXTRA_URL = "url"
|
||||
private const val EXTRA_CODEC = "codec"
|
||||
private const val EXTRA_BITRATE = "bitrate"
|
||||
private const val EXTRA_FAVICON = "favicon"
|
||||
private const val EXTRA_TRIGGER = "trigger"
|
||||
private const val NOTIFICATION_ID = 4401
|
||||
private const val CHANNEL_ID = "cachette_recording"
|
||||
private const val NOTIFY_EVERY_BYTES = 256 * 1024L
|
||||
private const val MIN_USEFUL_BYTES = 16 * 1024L
|
||||
private val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
|
||||
|
||||
fun start(context: Context, station: RadioStation, whenTitleContains: String? = null) {
|
||||
val intent = Intent(context, RecordingService::class.java)
|
||||
.setAction(ACTION_START)
|
||||
.putExtra(EXTRA_ID, station.id)
|
||||
.putExtra(EXTRA_NAME, station.name)
|
||||
.putExtra(EXTRA_URL, station.streamUrl)
|
||||
.putExtra(EXTRA_CODEC, station.codec)
|
||||
.putExtra(EXTRA_BITRATE, station.bitrate)
|
||||
.putExtra(EXTRA_FAVICON, station.faviconUrl)
|
||||
.putExtra(EXTRA_TRIGGER, whenTitleContains)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, RecordingService::class.java).setAction(ACTION_STOP)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package fr.tuxgyver.noxely.sources
|
||||
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
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.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Recherche dans archive.org.
|
||||
*
|
||||
* La recherche ne renvoie que des identifiants ; le fichier réellement lisible n'est déterminé
|
||||
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
|
||||
* trente éléments dont vingt-neuf seront ignorés.
|
||||
*/
|
||||
class ArchiveOrgCatalog(
|
||||
override val id: CatalogId = CatalogId.ARCHIVE,
|
||||
/**
|
||||
* Collections auxquelles se restreindre. Vide : tout archive.org.
|
||||
* `netlabels` et `freemusicarchive` ne contiennent que des publications sous licence libre,
|
||||
* ce qui donne un fonds indépendant sans les dépôts de titres commerciaux qu'on croise
|
||||
* ailleurs sur le site.
|
||||
*/
|
||||
private val collections: List<String> = emptyList(),
|
||||
) : Catalog {
|
||||
|
||||
override val languages = COMMON_LANGUAGES
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
|
||||
// dès la recherche plutôt que de les proposer puis d'échouer.
|
||||
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
|
||||
val collectionClause =
|
||||
collections.takeIf { it.isNotEmpty() }
|
||||
?.joinToString(" OR ")
|
||||
?.let { " AND collection:($it)" }
|
||||
.orEmpty()
|
||||
val q = URLEncoder.encode(
|
||||
"($query) AND mediatype:(audio OR movies)$languageClause$collectionClause" +
|
||||
" AND NOT access-restricted-item:true",
|
||||
"UTF-8",
|
||||
)
|
||||
val url = "https://archive.org/advancedsearch.php?q=$q" +
|
||||
"&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype&fl%5B%5D=item_size" +
|
||||
"&rows=30&page=1&output=json"
|
||||
|
||||
val body = fetch(url)
|
||||
val docs = json.parseToJsonElement(body).jsonObject["response"]
|
||||
?.jsonObject?.get("docs") as? JsonArray
|
||||
?: throw IllegalStateException("Réponse inattendue d'archive.org")
|
||||
|
||||
docs.mapNotNull { element ->
|
||||
val doc = element as? JsonObject ?: return@mapNotNull null
|
||||
val identifier = doc.text("identifier") ?: return@mapNotNull null
|
||||
CatalogItem(
|
||||
id = identifier,
|
||||
title = doc.text("title") ?: identifier,
|
||||
subtitle = doc.text("creator") ?: "Internet Archive",
|
||||
artworkUri = "https://archive.org/services/img/$identifier",
|
||||
hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio",
|
||||
sizeBytes = doc.text("item_size")?.toLongOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
|
||||
|
||||
if (metadata["metadata"]?.jsonObject?.text("access-restricted-item") == "true") {
|
||||
throw IllegalStateException("« ${item.title} » est en accès restreint sur archive.org")
|
||||
}
|
||||
|
||||
val wantsVideo = item.hint == "Vidéo"
|
||||
val extensions = if (wantsVideo) VIDEO_EXTENSIONS else AUDIO_EXTENSIONS
|
||||
val files = (metadata["files"] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject }
|
||||
|
||||
val playable = files.filter { file ->
|
||||
val name = file.text("name").orEmpty().lowercase()
|
||||
extensions.any { name.endsWith(it) }
|
||||
}
|
||||
if (playable.isEmpty()) {
|
||||
throw IllegalStateException(
|
||||
"Aucun fichier ${if (wantsVideo) "vidéo" else "audio"} lisible dans « ${item.title} »"
|
||||
)
|
||||
}
|
||||
|
||||
// Un élément expose l'original et ses dérivés du même morceau. On regroupe par nom de
|
||||
// base et on garde le plus léger de chaque groupe : autant de pistes que d'œuvres,
|
||||
// pas autant que de formats.
|
||||
playable.groupBy { it.text("name")!!.substringBeforeLast('.').lowercase() }
|
||||
.values
|
||||
.mapNotNull { variants ->
|
||||
variants.minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE }
|
||||
}
|
||||
.sortedBy { it.text("name") }
|
||||
.map { file ->
|
||||
val name = file.text("name")!!
|
||||
val seconds = file.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
||||
MediaEntry(
|
||||
id = stableId("ia:", "${item.id}/$name"),
|
||||
source = SourceKind.URL,
|
||||
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||
title = file.text("title") ?: name.substringBeforeLast('.'),
|
||||
subtitle = item.title,
|
||||
uri = "https://archive.org/download/${item.id}/${encodePath(name)}",
|
||||
artworkUri = item.artworkUri,
|
||||
durationMs = seconds * 1000,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
sizeBytes = file.text("size")?.toLongOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode le nom de fichier sans toucher aux séparateurs de chemin. */
|
||||
private fun encodePath(name: String): String =
|
||||
name.split('/').joinToString("/") {
|
||||
URLEncoder.encode(it, "UTF-8").replace("+", "%20")
|
||||
}
|
||||
|
||||
private fun fetch(url: String): String {
|
||||
val connection = openConnection(url)
|
||||
return try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("archive.org a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val AUDIO_EXTENSIONS = listOf(".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav")
|
||||
val VIDEO_EXTENSIONS = listOf(".mp4", ".m4v", ".webm", ".ogv")
|
||||
|
||||
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** archive.org renvoie tantôt une chaîne, tantôt un tableau : on prend la première valeur utile. */
|
||||
internal fun JsonObject.text(key: String): String? = when (val value = this[key]) {
|
||||
is JsonPrimitive -> value.content.takeIf { it.isNotBlank() }
|
||||
is JsonArray -> value.firstOrNull()?.let { (it as? JsonPrimitive)?.content }?.takeIf { it.isNotBlank() }
|
||||
else -> null
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package fr.tuxgyver.noxely.sources
|
||||
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
|
||||
/** Un résultat de recherche : un album, un livre ou un morceau, pas encore un fichier. */
|
||||
data class CatalogItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val artworkUri: String? = null,
|
||||
val hint: String = "",
|
||||
/** Taille de l'ensemble, quand le catalogue l'annonce. */
|
||||
val sizeBytes: Long? = null,
|
||||
)
|
||||
|
||||
/** Langue proposée au filtrage. [code] est la valeur attendue par l'API du catalogue. */
|
||||
data class CatalogLanguage(val code: String, val label: String)
|
||||
|
||||
/** Catalogue public interrogeable sans compte ni clé d'API. */
|
||||
interface Catalog {
|
||||
val id: CatalogId
|
||||
|
||||
/** Vide si le catalogue ne distingue pas les langues (la musique instrumentale, typiquement). */
|
||||
val languages: List<CatalogLanguage> get() = emptyList()
|
||||
|
||||
suspend fun search(query: String, language: String? = null): Result<List<CatalogItem>>
|
||||
|
||||
/**
|
||||
* Les pistes d'un résultat, avec leur taille.
|
||||
*
|
||||
* Un album ou un livre en compte plusieurs : c'est à l'utilisateur de choisir lesquelles il
|
||||
* emporte, pas à l'application de décider pour lui.
|
||||
*/
|
||||
suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>>
|
||||
}
|
||||
|
||||
enum class CatalogId(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val license: String,
|
||||
val suggestion: String,
|
||||
) {
|
||||
ARCHIVE(
|
||||
title = "Internet Archive",
|
||||
description = "Musique, concerts et films versés au domaine public ou sous licence libre.",
|
||||
license = "Domaine public / Creative Commons",
|
||||
suggestion = "Grateful Dead",
|
||||
),
|
||||
LIBRIVOX(
|
||||
title = "LibriVox",
|
||||
description = "Livres audio lus par des bénévoles, tous dans le domaine public.",
|
||||
license = "Domaine public",
|
||||
suggestion = "Jules Verne",
|
||||
),
|
||||
UNDERGROUND(
|
||||
title = "Underground",
|
||||
description = "Netlabels et Free Music Archive : des dizaines de milliers de morceaux d'artistes indépendants, publiés sous licence libre.",
|
||||
license = "Creative Commons / netlabels",
|
||||
suggestion = "ambient",
|
||||
),
|
||||
WIKIMEDIA(
|
||||
title = "Wikimedia Commons",
|
||||
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
|
||||
license = "Domaine public / Creative Commons",
|
||||
suggestion = "Chopin",
|
||||
),
|
||||
}
|
||||
|
||||
/** Langues les plus fournies, communes à LibriVox et archive.org. */
|
||||
val COMMON_LANGUAGES = listOf(
|
||||
CatalogLanguage("French", "Français"),
|
||||
CatalogLanguage("English", "Anglais"),
|
||||
CatalogLanguage("German", "Allemand"),
|
||||
CatalogLanguage("Spanish", "Espagnol"),
|
||||
CatalogLanguage("Italian", "Italien"),
|
||||
CatalogLanguage("Dutch", "Néerlandais"),
|
||||
)
|
||||
@@ -1,106 +0,0 @@
|
||||
package fr.tuxgyver.noxely.sources
|
||||
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
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 java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Recherche dans LibriVox.
|
||||
*
|
||||
* L'API a deux pièges, tous deux vérifiés contre le service réel :
|
||||
* le champ `title` fait une correspondance **exacte** sauf s'il est préfixé par `^`
|
||||
* (`title=Alice` ne donne rien, `title=^Alice` donne vingt-trois livres), et le champ `author`
|
||||
* ne matche que le **nom de famille** (`author=Jules Verne` ne donne rien, `author=Verne` si).
|
||||
* Un « aucun résultat » se présente enfin sous la forme d'un HTTP 404, pas d'une liste vide.
|
||||
*
|
||||
* D'où trois interrogations combinées et dédoublonnées, plutôt qu'une seule qui échouerait.
|
||||
*
|
||||
* Chaque livre publie un flux RSS : la résolution réutilise l'import de podcast plutôt que de
|
||||
* redécrire un parcours de chapitres.
|
||||
*/
|
||||
class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
||||
|
||||
override val id = CatalogId.LIBRIVOX
|
||||
|
||||
override val languages = COMMON_LANGUAGES
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
// Le paramètre `language` de l'API est accepté puis ignoré : vérifié, `language=French`
|
||||
// seul renvoie des livres anglais. Le tri se fait donc sur le champ `language` de
|
||||
// chaque livre, et on demande davantage de résultats pour qu'il en reste après filtrage.
|
||||
val limit = if (language == null) 25 else 100
|
||||
val cleaned = query.trim()
|
||||
val term = URLEncoder.encode(cleaned, "UTF-8")
|
||||
val words = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
|
||||
val criteria = buildList {
|
||||
add("title=^$term")
|
||||
add("author=$term")
|
||||
// « Jules Verne » ne matche que sur « Verne » : on retente avec le nom de famille.
|
||||
if (words.size > 1) add("author=${URLEncoder.encode(words.last(), "UTF-8")}")
|
||||
}
|
||||
|
||||
// Les trois critères sont indépendants : les lancer en parallèle ramène l'attente
|
||||
// à celle de la requête la plus lente, au lieu de leur somme.
|
||||
coroutineScope {
|
||||
criteria.map { async { books(it, limit, language) } }.awaitAll()
|
||||
}.flatten().distinctBy { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> =
|
||||
rss.import(item.id).map { feed -> feed.episodes }
|
||||
|
||||
private fun books(criterion: String, limit: Int, language: String?): List<CatalogItem> {
|
||||
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=$limit&$criterion"
|
||||
// Le service met 14 à 16 secondes à répondre, quelle que soit la taille demandée :
|
||||
// c'est sa latence propre, pas le volume. D'où une patience adaptée.
|
||||
val connection = openConnection(url, readTimeoutMs = 45_000)
|
||||
val body = try {
|
||||
when (connection.responseCode) {
|
||||
// LibriVox répond 404 quand le critère ne donne rien : ce n'est pas une panne.
|
||||
404 -> return emptyList()
|
||||
in 200..299 -> connection.inputStream.bufferedReader().readText()
|
||||
else -> throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray ?: return emptyList()
|
||||
return books.mapNotNull { element ->
|
||||
val book = element as? JsonObject ?: return@mapNotNull null
|
||||
if (language != null && !book.text("language").equals(language, ignoreCase = true)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val feed = book.text("url_rss") ?: return@mapNotNull null
|
||||
CatalogItem(
|
||||
id = feed,
|
||||
title = book.text("title") ?: "Livre audio",
|
||||
subtitle = book.authors(),
|
||||
hint = book.text("totaltime")?.let { "Livre audio · $it" } ?: "Livre audio",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.authors(): String {
|
||||
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
|
||||
return authors.mapNotNull { it as? JsonObject }
|
||||
.joinToString(", ") { author ->
|
||||
listOfNotNull(author.text("first_name"), author.text("last_name")).joinToString(" ")
|
||||
}
|
||||
.ifBlank { "LibriVox" }
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package fr.tuxgyver.noxely.sources
|
||||
|
||||
import android.util.Xml
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
data class PodcastFeed(
|
||||
val title: String,
|
||||
val artworkUri: String?,
|
||||
val episodes: List<MediaEntry>,
|
||||
)
|
||||
|
||||
/** Importe un flux RSS de podcast : chaque `<enclosure>` devient une entrée téléchargeable. */
|
||||
class RssImporter {
|
||||
|
||||
suspend fun import(feedUrl: String): Result<PodcastFeed> = withContext(Dispatchers.IO) {
|
||||
val url = feedUrl.trim()
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://"))
|
||||
}
|
||||
runCatching {
|
||||
val connection = openConnection(url)
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
|
||||
}
|
||||
parse(connection.inputStream.bufferedReader().readText())
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(xml: String): PodcastFeed {
|
||||
val parser = Xml.newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
setInput(xml.reader())
|
||||
}
|
||||
|
||||
var feedTitle = ""
|
||||
var feedImage: String? = null
|
||||
val episodes = mutableListOf<MediaEntry>()
|
||||
|
||||
var item: Draft? = null
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when (parser.name) {
|
||||
"item", "entry" -> item = Draft()
|
||||
"title" -> {
|
||||
val value = parser.textOrEmpty()
|
||||
if (item != null) item.title = value else if (feedTitle.isEmpty()) feedTitle = value
|
||||
}
|
||||
"enclosure" -> if (item != null) {
|
||||
item.url = parser.getAttributeValue(null, "url")
|
||||
item.type = parser.getAttributeValue(null, "type").orEmpty()
|
||||
}
|
||||
"itunes:image" -> {
|
||||
val href = parser.getAttributeValue(null, "href")
|
||||
if (item != null) item.image = href else if (feedImage == null) feedImage = href
|
||||
}
|
||||
"itunes:duration" -> if (item != null) item.duration = parser.textOrEmpty()
|
||||
"pubDate" -> if (item != null) item.pubDate = parser.textOrEmpty()
|
||||
"url" -> if (item == null && feedImage == null) feedImage = parser.textOrEmpty()
|
||||
}
|
||||
|
||||
XmlPullParser.END_TAG -> if (parser.name == "item" || parser.name == "entry") {
|
||||
item?.toEntry(feedTitle, feedImage)?.let(episodes::add)
|
||||
item = null
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
|
||||
if (episodes.isEmpty()) {
|
||||
throw IllegalStateException("Aucun épisode téléchargeable trouvé dans ce flux")
|
||||
}
|
||||
return PodcastFeed(
|
||||
title = feedTitle.ifEmpty { "Podcast" },
|
||||
artworkUri = feedImage,
|
||||
episodes = episodes.take(MAX_EPISODES),
|
||||
)
|
||||
}
|
||||
|
||||
private class Draft {
|
||||
var title: String = ""
|
||||
var url: String? = null
|
||||
var type: String = ""
|
||||
var image: String? = null
|
||||
var duration: String = ""
|
||||
var pubDate: String = ""
|
||||
|
||||
fun toEntry(feedTitle: String, feedImage: String?): MediaEntry? {
|
||||
val mediaUrl = url?.takeIf { it.isNotBlank() } ?: return null
|
||||
return MediaEntry(
|
||||
id = stableId("pod:", mediaUrl),
|
||||
source = SourceKind.PODCAST,
|
||||
kind = if (type.startsWith("video/")) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||
title = title.ifBlank { "Épisode" },
|
||||
subtitle = feedTitle.ifBlank { "Podcast" },
|
||||
uri = mediaUrl,
|
||||
artworkUri = image ?: feedImage,
|
||||
durationMs = parseDuration(duration),
|
||||
addedAt = parseDate(pubDate),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_EPISODES = 100
|
||||
|
||||
fun XmlPullParser.textOrEmpty(): String =
|
||||
runCatching { nextText().trim() }.getOrDefault("")
|
||||
|
||||
/** `1:02:03`, `12:34` ou un nombre de secondes. */
|
||||
fun parseDuration(raw: String): Long {
|
||||
if (raw.isBlank()) return 0L
|
||||
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
|
||||
return when (parts.size) {
|
||||
1 -> parts[0] * 1000
|
||||
2 -> (parts[0] * 60 + parts[1]) * 1000
|
||||
3 -> (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
|
||||
fun parseDate(raw: String): Long {
|
||||
if (raw.isBlank()) return System.currentTimeMillis()
|
||||
val formats = listOf("EEE, dd MMM yyyy HH:mm:ss Z", "EEE, dd MMM yyyy HH:mm:ss zzz")
|
||||
for (pattern in formats) {
|
||||
runCatching { SimpleDateFormat(pattern, Locale.US).parse(raw)?.time }
|
||||
.getOrNull()?.let { return it }
|
||||
}
|
||||
return System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package fr.tuxgyver.noxely.sources
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Importe un lien direct vers un fichier média (mp3, m4a, mp4, webm…). */
|
||||
class UrlImporter {
|
||||
|
||||
suspend fun import(rawUrl: String): Result<MediaEntry> = withContext(Dispatchers.IO) {
|
||||
val url = rawUrl.trim()
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://"))
|
||||
}
|
||||
runCatching {
|
||||
val connection = openConnection(url, "HEAD")
|
||||
val contentType = try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.contentType.orEmpty()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
val kind = kindOf(contentType, url)
|
||||
?: throw IllegalStateException("Ce lien ne pointe pas vers un fichier audio ou vidéo")
|
||||
|
||||
val name = url.toUri().lastPathSegment
|
||||
?.substringBeforeLast('.')
|
||||
?.replace(Regex("[_-]+"), " ")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Média importé"
|
||||
|
||||
MediaEntry(
|
||||
id = stableId("url:", url),
|
||||
source = SourceKind.URL,
|
||||
kind = kind,
|
||||
title = name,
|
||||
subtitle = url.toUri().host ?: "Lien direct",
|
||||
uri = url,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun kindOf(contentType: String, url: String): MediaKind? {
|
||||
val mime = contentType.substringBefore(';').trim().lowercase()
|
||||
return when {
|
||||
mime.startsWith("video/") -> MediaKind.VIDEO
|
||||
mime.startsWith("audio/") -> MediaKind.AUDIO
|
||||
mime == "application/vnd.apple.mpegurl" || mime == "application/x-mpegurl" -> MediaKind.VIDEO
|
||||
else -> when (url.substringAfterLast('.', "").lowercase().substringBefore('?')) {
|
||||
"mp3", "m4a", "aac", "ogg", "opus", "flac", "wav" -> MediaKind.AUDIO
|
||||
"mp4", "mkv", "webm", "m4v", "mov" -> MediaKind.VIDEO
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package fr.tuxgyver.noxely.sources
|
||||
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Recherche de fichiers audio sur Wikimedia Commons.
|
||||
*
|
||||
* Retenu après avoir écarté deux candidats vérifiés : Openverse refuse désormais l'accès anonyme
|
||||
* (401 sur huit requêtes sur huit) et ccMixter exige un en-tête `Referer` que Media3 ne permet pas
|
||||
* de poser par téléchargement. Commons, lui, sert ses fichiers directement depuis
|
||||
* upload.wikimedia.org, sans clé ni en-tête particulier, et annonce leur taille.
|
||||
*
|
||||
* Chaque résultat est déjà un fichier unique : la liste de pistes n'en contient donc qu'une.
|
||||
*/
|
||||
class WikimediaCatalog : Catalog {
|
||||
|
||||
override val id = CatalogId.WIKIMEDIA
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val search = URLEncoder.encode("filetype:audio ${query.trim()}", "UTF-8")
|
||||
val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json" +
|
||||
"&generator=search&gsrsearch=$search&gsrnamespace=6&gsrlimit=30" +
|
||||
"&prop=imageinfo&iiprop=url%7Csize%7Cmime%7Cextmetadata"
|
||||
|
||||
val connection = openConnection(url, readTimeoutMs = 30_000)
|
||||
val body = try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Commons a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
val pages = json.parseToJsonElement(body).jsonObject["query"]
|
||||
?.jsonObject?.get("pages")?.jsonObject
|
||||
?: return@runCatching emptyList()
|
||||
|
||||
pages.values.mapNotNull { element ->
|
||||
val page = element as? JsonObject ?: return@mapNotNull null
|
||||
val info = page["imageinfo"]?.jsonArray?.firstOrNull()?.jsonObject
|
||||
?: return@mapNotNull null
|
||||
val fileUrl = info.text("url") ?: return@mapNotNull null
|
||||
if (!PLAYABLE.any { fileUrl.substringBefore('?').lowercase().endsWith(it) }) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val licence = info["extmetadata"]?.jsonObject
|
||||
?.get("LicenseShortName")?.jsonObject?.text("value")
|
||||
|
||||
CatalogItem(
|
||||
id = fileUrl,
|
||||
// Les titres Commons sont préfixés « File: » : inutile à l'écran.
|
||||
title = page.text("title")?.removePrefix("File:")?.substringBeforeLast('.')
|
||||
?: "Sans titre",
|
||||
subtitle = "Wikimedia Commons",
|
||||
hint = licence ?: "Domaine public / CC",
|
||||
sizeBytes = info.text("size")?.toLongOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = Result.success(
|
||||
listOf(
|
||||
MediaEntry(
|
||||
id = stableId("wm:", item.id),
|
||||
source = SourceKind.URL,
|
||||
kind = MediaKind.AUDIO,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
uri = item.id,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
sizeBytes = item.sizeBytes,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private companion object {
|
||||
/** Formats que Media3 sait lire parmi ceux que Commons héberge. */
|
||||
val PLAYABLE = listOf(".ogg", ".oga", ".opus", ".flac", ".wav", ".mp3", ".m4a")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package fr.tuxgyver.noxely.sync
|
||||
|
||||
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.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
/** Coordonnées du serveur et état de la dernière synchronisation. */
|
||||
@Serializable
|
||||
data class ServerConfig(
|
||||
val baseUrl: String = "",
|
||||
val token: String = "",
|
||||
/** Horodatage du dernier média reçu : le serveur ne renvoie que ce qui a changé après. */
|
||||
val cursor: Long = 0L,
|
||||
val lastSyncAt: Long = 0L,
|
||||
val lastResult: String = "",
|
||||
) {
|
||||
val isConfigured: Boolean get() = baseUrl.isNotBlank() && token.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Réglages du serveur, persistés localement.
|
||||
*
|
||||
* Le jeton ne quitte jamais l'appareil autrement que dans l'en-tête des requêtes vers le serveur
|
||||
* désigné : c'est la seule adresse que l'application contacte une fois la bascule faite.
|
||||
*/
|
||||
class ServerSettings(context: Context, private val scope: CoroutineScope) {
|
||||
|
||||
private val file = File(context.filesDir, "server.json")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _config = MutableStateFlow(ServerConfig())
|
||||
val config: StateFlow<ServerConfig> = _config.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch { _config.value = read() }
|
||||
}
|
||||
|
||||
suspend fun update(transform: (ServerConfig) -> ServerConfig) {
|
||||
val updated = transform(_config.value)
|
||||
_config.value = updated
|
||||
write(updated)
|
||||
}
|
||||
|
||||
private suspend fun read(): ServerConfig = withContext(Dispatchers.IO) {
|
||||
if (!file.exists()) return@withContext ServerConfig()
|
||||
runCatching { json.decodeFromString<ServerConfig>(file.readText()) }
|
||||
.getOrElse { ServerConfig() }
|
||||
}
|
||||
|
||||
private suspend fun write(config: ServerConfig) = withContext(Dispatchers.IO) {
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(json.encodeToString(config))
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package fr.tuxgyver.noxely.sync
|
||||
|
||||
import android.content.Context
|
||||
import fr.tuxgyver.noxely.library.ImportedStore
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
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.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
|
||||
|
||||
data class SyncResult(val added: Int, val failed: Int, val message: String)
|
||||
|
||||
/**
|
||||
* Client de synchronisation.
|
||||
*
|
||||
* Le téléphone ne cherche plus, ne guette plus et n'enregistre plus : il demande au serveur ce
|
||||
* qui est apparu depuis son dernier passage, télécharge les fichiers prêts, et se rendort. C'est
|
||||
* là qu'est l'économie de batterie — tout le travail d'écoute et de collecte a migré ailleurs.
|
||||
*/
|
||||
class SyncClient(
|
||||
private val context: Context,
|
||||
private val settings: ServerSettings,
|
||||
private val imported: ImportedStore,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** Vérifie que le serveur répond et que le jeton est accepté. */
|
||||
suspend fun probe(baseUrl: String, token: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val name = request("${baseUrl.trimEnd('/')}/api/health", token = null)
|
||||
.let { json.parseToJsonElement(it).jsonObject }
|
||||
.let { "${it["name"]?.jsonPrimitive?.content} ${it["version"]?.jsonPrimitive?.content}" }
|
||||
// Une réponse publique ne prouve rien sur le jeton : on interroge aussi un point protégé.
|
||||
request("${baseUrl.trimEnd('/')}/api/library?since=0", token)
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
|
||||
val config = settings.config.value
|
||||
if (!config.isConfigured) return@withContext SyncResult(0, 0, "Serveur non configuré")
|
||||
|
||||
val base = config.baseUrl.trimEnd('/')
|
||||
val body = runCatching { request("$base/api/library?since=${config.cursor}", config.token) }
|
||||
.getOrElse { return@withContext SyncResult(0, 0, it.message ?: "Serveur injoignable") }
|
||||
|
||||
val payload = json.parseToJsonElement(body).jsonObject
|
||||
val items = (payload["items"] as? JsonArray).orEmpty()
|
||||
val cursor = payload["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: config.cursor
|
||||
|
||||
var added = 0
|
||||
var failed = 0
|
||||
for (element in items) {
|
||||
val item = element as? JsonObject ?: continue
|
||||
val id = item.text("id") ?: continue
|
||||
val title = item.text("title") ?: "Sans titre"
|
||||
val mime = item.text("mime") ?: "audio/mpeg"
|
||||
val extension = if (mime.contains("aac")) "aac" else if (mime.contains("mp4")) "mp4" else "mp3"
|
||||
|
||||
val sink = RecordingSink(context, "${title.take(80)}.$extension", mime)
|
||||
val outcome = runCatching {
|
||||
val connection = openConnection("$base/api/media/$id/file", readTimeoutMs = 120_000)
|
||||
.apply { setRequestProperty("X-Noxely-Token", config.token) }
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("HTTP ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.use { input ->
|
||||
sink.open().use { output -> input.copyTo(output) }
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
sink.finish(title, item.text("duration_ms")?.toLongOrNull() ?: 0L)
|
||||
}.getOrNull()
|
||||
|
||||
if (outcome == null) {
|
||||
sink.abort()
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
val (uri, size) = outcome
|
||||
imported.add(
|
||||
listOf(
|
||||
MediaEntry(
|
||||
id = "srv:$id",
|
||||
source = SourceKind.SERVER,
|
||||
kind = if (mime.startsWith("video")) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||
title = title,
|
||||
subtitle = item.text("artist")?.takeIf { it.isNotBlank() } ?: "Serveur Noxely",
|
||||
uri = uri,
|
||||
artworkUri = item.text("artwork_url"),
|
||||
durationMs = item.text("duration_ms")?.toLongOrNull() ?: 0L,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
sizeBytes = size,
|
||||
)
|
||||
)
|
||||
)
|
||||
added++
|
||||
}
|
||||
|
||||
// Le curseur n'avance que si tout est passé : un échec sera retenté au prochain tour.
|
||||
settings.update {
|
||||
it.copy(
|
||||
cursor = if (failed == 0) cursor else it.cursor,
|
||||
lastSyncAt = System.currentTimeMillis(),
|
||||
lastResult = when {
|
||||
added == 0 && failed == 0 -> "À jour"
|
||||
failed == 0 -> "$added titre(s) reçus"
|
||||
else -> "$added reçus, $failed en échec"
|
||||
},
|
||||
)
|
||||
}
|
||||
SyncResult(added, failed, settings.config.value.lastResult)
|
||||
}
|
||||
|
||||
private fun request(url: String, token: String?): String {
|
||||
val connection = openConnection(url, readTimeoutMs = 30_000).apply {
|
||||
token?.let { setRequestProperty("X-Noxely-Token", it) }
|
||||
}
|
||||
return try {
|
||||
when (connection.responseCode) {
|
||||
401 -> throw IllegalStateException("Jeton refusé par le serveur")
|
||||
in 200..299 -> connection.inputStream.bufferedReader().readText()
|
||||
else -> throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
|
||||
|
||||
private fun JsonObject.text(key: String): String? =
|
||||
this[key]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() && it != "null" }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package fr.tuxgyver.noxely.sync
|
||||
|
||||
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 java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Synchronisation planifiée.
|
||||
*
|
||||
* Wi-Fi non facturé et batterie non faible : le téléphone ne se réveille que dans les conditions
|
||||
* où un téléchargement ne coûte rien. C'est le réglage le plus sobre — le serveur, lui, a déjà
|
||||
* fait tout le travail d'écoute et de collecte.
|
||||
*/
|
||||
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val container = (applicationContext as NoxelyApp).container
|
||||
if (!container.serverSettings.config.value.isConfigured) return Result.success()
|
||||
val outcome = runCatching { container.sync.sync() }.getOrNull()
|
||||
return if (outcome == null) Result.retry() else Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "noxely-sync"
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,37 +38,26 @@ import androidx.navigation.compose.rememberNavController
|
||||
import fr.tuxgyver.noxely.AppContainer
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
import fr.tuxgyver.noxely.sources.CatalogId
|
||||
import fr.tuxgyver.noxely.ui.about.AboutScreen
|
||||
import fr.tuxgyver.noxely.ui.browse.CatalogBrowseScreen
|
||||
import fr.tuxgyver.noxely.ui.browse.CatalogItemScreen
|
||||
import fr.tuxgyver.noxely.ui.browse.SourceBrowseScreen
|
||||
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.server.ServerScreen
|
||||
import fr.tuxgyver.noxely.ui.playlists.AddToPlaylistSheet
|
||||
import fr.tuxgyver.noxely.ui.playlists.PlaylistDetailScreen
|
||||
import fr.tuxgyver.noxely.ui.playlists.PlaylistsScreen
|
||||
import fr.tuxgyver.noxely.ui.player.NowPlayingScreen
|
||||
import fr.tuxgyver.noxely.ui.sources.SourcesScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
||||
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
|
||||
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
|
||||
PLAYLISTS("playlists", "Playlists", Icons.Rounded.PlaylistPlay),
|
||||
SOURCES("sources", "Sources", Icons.Rounded.Tune),
|
||||
SERVER("server", "Réglages", Icons.Rounded.Tune),
|
||||
}
|
||||
|
||||
private const val BROWSE_ROUTE = "browse/{source}"
|
||||
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
||||
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) {
|
||||
@@ -157,26 +146,30 @@ fun NoxelyRoot(container: AppContainer) {
|
||||
AboutScreen(onBack = navController::popBackStack, contentPadding = padding)
|
||||
}
|
||||
|
||||
composable(RADIO_ROUTE) {
|
||||
RadioScreen(
|
||||
composable(PLAYLIST_ROUTE) { entry ->
|
||||
PlaylistDetailScreen(
|
||||
container = container,
|
||||
playlistId = entry.arguments?.getString("playlist").orEmpty(),
|
||||
currentMediaId = playerState.mediaId,
|
||||
onBack = navController::popBackStack,
|
||||
onMessage = notify,
|
||||
onOpenWatches = { navController.navigate(WATCHES_ROUTE) },
|
||||
onPlay = playFrom,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(WATCHES_ROUTE) {
|
||||
WatchListScreen(
|
||||
composable(Tab.SERVER.route) {
|
||||
ServerScreen(
|
||||
container = container,
|
||||
onBack = navController::popBackStack,
|
||||
onOpenAbout = { navController.navigate(ABOUT_ROUTE) },
|
||||
onMessage = notify,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(ABOUT_ROUTE) {
|
||||
AboutScreen(onBack = navController::popBackStack, contentPadding = padding)
|
||||
}
|
||||
|
||||
composable(PLAYLIST_ROUTE) { entry ->
|
||||
PlaylistDetailScreen(
|
||||
container = container,
|
||||
@@ -188,55 +181,6 @@ fun NoxelyRoot(container: AppContainer) {
|
||||
)
|
||||
}
|
||||
|
||||
composable(Tab.SOURCES.route) {
|
||||
SourcesScreen(
|
||||
container = container,
|
||||
contentPadding = padding,
|
||||
onMessage = notify,
|
||||
onBrowseSource = { navController.navigate("browse/${it.name}") },
|
||||
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
|
||||
onBrowseRadio = { navController.navigate(RADIO_ROUTE) },
|
||||
onOpenAbout = { navController.navigate(ABOUT_ROUTE) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(BROWSE_ROUTE) { entry ->
|
||||
val source = SourceKind.valueOf(entry.arguments?.getString("source") ?: SourceKind.LOCAL.name)
|
||||
SourceBrowseScreen(
|
||||
container = container,
|
||||
source = source,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onBack = navController::popBackStack,
|
||||
onPlay = play,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(CATALOG_ROUTE) { entry ->
|
||||
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
|
||||
CatalogBrowseScreen(
|
||||
container = container,
|
||||
catalogId = catalog,
|
||||
onBack = navController::popBackStack,
|
||||
onOpenItem = { item ->
|
||||
container.openedCatalogItem = item
|
||||
navController.navigate("catalog/${catalog.name}/item")
|
||||
},
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(CATALOG_ITEM_ROUTE) { entry ->
|
||||
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
|
||||
CatalogItemScreen(
|
||||
container = container,
|
||||
catalogId = catalog,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onBack = navController::popBackStack,
|
||||
onMessage = notify,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,4 +232,4 @@ private fun NavHostController.switchTab(route: String) {
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import fr.tuxgyver.noxely.BuildConfig
|
||||
object AppInfo {
|
||||
|
||||
const val NAME = "Noxely"
|
||||
const val TAGLINE = "Mettez la musique de côté pendant que vous avez du réseau."
|
||||
const val TAGLINE = "Le serveur collecte, le téléphone écoute."
|
||||
val VERSION: String get() = BuildConfig.VERSION_NAME
|
||||
|
||||
const val REPOSITORY = "https://git.tuxtech.fr/tuxgyver/Noxely"
|
||||
@@ -27,24 +27,18 @@ object AppInfo {
|
||||
/** Ce que fait l'application, du point de vue de qui l'utilise. */
|
||||
val FEATURES = listOf(
|
||||
"Écouter sans réseau" to
|
||||
"Ce que vous mettez de côté reste lisible dans le métro, en avion ou forfait épuisé. " +
|
||||
"Audio et vidéo, avec les commandes au casque et sur l'écran verrouillé.",
|
||||
"Choisir piste par piste" to
|
||||
"Un album ou un livre affiche ses pistes avec leur durée et leur taille. Vous cochez " +
|
||||
"ce que vous emportez, et le bouton annonce le poids exact avant de lancer.",
|
||||
"Essayer avant de garder" to
|
||||
"Un appui sur une piste la joue en ligne, sans rien mettre en cache. On décide après.",
|
||||
"Puiser dans des catalogues libres" to
|
||||
"Internet Archive, Underground (netlabels et Free Music Archive), LibriVox et " +
|
||||
"Wikimedia Commons, sans compte ni clé, avec filtrage par langue.",
|
||||
"Écouter et enregistrer la radio" to
|
||||
"Les stations les plus écoutées, ce qu'elles diffusent à l'instant, et " +
|
||||
"l'enregistrement d'un direct — qui prend le nom du morceau annoncé.",
|
||||
"Guetter un morceau" to
|
||||
"Vous nommez un titre : l'enregistrement se déclenche dès que la station l'annonce, " +
|
||||
"et s'arrête à la fin du morceau.",
|
||||
"Ce que le serveur a collecté est sur le téléphone, lisible dans le métro, en avion " +
|
||||
"ou forfait épuisé. Audio et vidéo, commandes au casque et sur l'écran verrouillé.",
|
||||
"Se synchroniser sans y penser" to
|
||||
"Toutes les six heures, en Wi-Fi non facturé et batterie suffisante, l'application " +
|
||||
"demande au serveur ce qui est neuf et le récupère. Le reste du temps, elle dort.",
|
||||
"Laisser le travail au serveur" to
|
||||
"Recherche dans les catalogues, guet des morceaux sur les radios, enregistrement des " +
|
||||
"directs : tout cela se pilote depuis l'interface web du serveur, pas depuis le mobile.",
|
||||
"Organiser en playlists" to
|
||||
"Des listes nommées, alimentées d'un appui long, lues à la suite.",
|
||||
"Retrouver les pochettes" to
|
||||
"Les titres sans image en reçoivent une, cherchée sur leur nom.",
|
||||
"Rester à jour" to
|
||||
"L'application surveille les publications du dépôt, télécharge la nouvelle version " +
|
||||
"en Wi-Fi et propose de l'installer.",
|
||||
@@ -52,20 +46,22 @@ object AppInfo {
|
||||
|
||||
/** Chaque promesse est tenue par du code, et dit où. */
|
||||
val GUARANTEES_INTRO =
|
||||
"Noxely est un outil personnel : rien n'y transite par un service tiers, et rien ne s'y " +
|
||||
"télécharge sans un geste de votre part."
|
||||
"Noxely est un outil personnel : le téléphone ne parle qu'à votre serveur, et rien n'y " +
|
||||
"transite par un service tiers."
|
||||
|
||||
val GUARANTEES = listOf(
|
||||
"Aucun compte, aucune inscription, aucune télémétrie : le projet n'embarque aucune " +
|
||||
"bibliothèque d'analyse d'audience.",
|
||||
"Les seules connexions sortantes sont celles des adresses que vous ajoutez et des " +
|
||||
"catalogues que vous interrogez.",
|
||||
"La seule adresse que le mobile contacte est celle de votre serveur, plus le dépôt des " +
|
||||
"mises à jour. Les catalogues et les radios ne sont joints que par le serveur.",
|
||||
"Le mobile ne se réveille qu'en Wi-Fi non facturé et batterie suffisante : il ne balaie " +
|
||||
"plus aucune radio, ne cherche plus et n'enregistre plus.",
|
||||
"Le cache ne contient que ce que vous avez demandé à garder : rien n'y est écrit pendant " +
|
||||
"une écoute en ligne, et rien n'en est effacé sans votre geste.",
|
||||
"Le contenu mis en cache est exclu des sauvegardes dans le nuage et des transferts " +
|
||||
"d'appareil à appareil.",
|
||||
"Un téléchargement interrompu par une panne de serveur repart seul, trois fois, en " +
|
||||
"espaçant les tentatives ; sans réseau, il attend son retour.",
|
||||
"Une synchronisation partielle ne fait pas avancer le curseur : ce qui a échoué est " +
|
||||
"repris au passage suivant, jamais perdu.",
|
||||
"Les catalogues proposés ne présentent que des œuvres libres de droits ou du domaine " +
|
||||
"public ; les éléments en accès restreint sont écartés avant même d'être affichés.",
|
||||
"Le jeton qui sert à vérifier les mises à jour est en lecture seule et ne peut rien publier.",
|
||||
@@ -74,10 +70,11 @@ object AppInfo {
|
||||
/** Couche → outils. Six lignes suffisent. */
|
||||
val STACK = listOf(
|
||||
"Interface" to "Jetpack Compose, Material 3, thèmes clair et sombre",
|
||||
"Serveur" to "Python, FastAPI, uvicorn, SQLite, service systemd",
|
||||
"Lecture" to "Media3 / ExoPlayer, MediaSessionService, notification système",
|
||||
"Hors connexion" to "Cache Media3 non évinçant, WorkManager pour la reprise",
|
||||
"Hors connexion" to "Fichiers rangés dans MediaStore, lisibles par tout lecteur",
|
||||
"Bibliothèque" to "MediaStore pour l'appareil, fichiers JSON pour les imports",
|
||||
"Réseau" to "HttpURLConnection, kotlinx.serialization, flux ICY pour la radio",
|
||||
"Synchronisation" to "HTTP avec jeton, curseur incrémental, WorkManager",
|
||||
"Publication" to "APK signé, releases Gitea, mise à jour intégrée",
|
||||
)
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package fr.tuxgyver.noxely.ui.browse
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import fr.tuxgyver.noxely.AppContainer
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
import fr.tuxgyver.noxely.sources.CatalogId
|
||||
import fr.tuxgyver.noxely.sources.CatalogItem
|
||||
import fr.tuxgyver.noxely.ui.components.Artwork
|
||||
import fr.tuxgyver.noxely.ui.components.formatBytes
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Recherche dans un catalogue libre de droits.
|
||||
*
|
||||
* Un résultat n'est pas un fichier mais un album, un livre ou un morceau : l'ouvrir mène à la
|
||||
* liste de ses pistes, où l'on écoute et où l'on choisit ce qu'on emporte.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CatalogBrowseScreen(
|
||||
container: AppContainer,
|
||||
catalogId: CatalogId,
|
||||
onBack: () -> Unit,
|
||||
onOpenItem: (CatalogItem) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val catalog = container.catalogs.getValue(catalogId)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
|
||||
var language by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun runSearch() {
|
||||
if (query.isBlank()) return
|
||||
searching = true
|
||||
error = null
|
||||
scope.launch {
|
||||
catalog.search(query, language).fold(
|
||||
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
|
||||
onFailure = { error = it.message ?: "Recherche impossible" },
|
||||
)
|
||||
searching = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(catalogId, language) { runSearch() }
|
||||
|
||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(catalogId.title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = catalogId.license,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Rechercher dans ${catalogId.title}") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
|
||||
if (catalog.languages.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = language == null,
|
||||
onClick = { language = null },
|
||||
label = { Text("Toutes langues") },
|
||||
)
|
||||
catalog.languages.forEach { option ->
|
||||
FilterChip(
|
||||
selected = language == option.code,
|
||||
onClick = { language = option.code },
|
||||
label = { Text(option.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
|
||||
when {
|
||||
searching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
error != null -> Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = error!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
else -> LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(results, key = { it.id }) { item ->
|
||||
CatalogRow(item = item, onOpen = { onOpenItem(item) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogRow(item: CatalogItem, onOpen: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpen),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
) {
|
||||
Artwork(
|
||||
uri = item.artworkUri,
|
||||
title = item.title,
|
||||
kind = if (item.hint.contains("Vidéo")) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
item.subtitle.takeIf { it.isNotBlank() },
|
||||
item.hint.takeIf { it.isNotBlank() },
|
||||
item.sizeBytes?.let { "~${formatBytes(it)}" },
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = "Voir les pistes",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package fr.tuxgyver.noxely.ui.browse
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.GraphicEq
|
||||
import androidx.compose.material.icons.rounded.PlayCircleOutline
|
||||
import androidx.compose.material3.Checkbox
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import fr.tuxgyver.noxely.AppContainer
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.sources.CatalogId
|
||||
import fr.tuxgyver.noxely.ui.components.formatBytes
|
||||
import fr.tuxgyver.noxely.ui.components.formatDuration
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Les pistes d'un résultat de catalogue.
|
||||
*
|
||||
* Un album ou un livre en compte souvent des dizaines : chacune s'écoute d'abord en ligne, et
|
||||
* n'est mise en cache que si on la coche. La taille de la sélection est annoncée avant l'envoi.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CatalogItemScreen(
|
||||
container: AppContainer,
|
||||
catalogId: CatalogId,
|
||||
currentMediaId: String?,
|
||||
onBack: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val item = container.openedCatalogItem
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var tracks by remember { mutableStateOf<List<MediaEntry>>(emptyList()) }
|
||||
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(item?.id) {
|
||||
val opened = item ?: run {
|
||||
onBack()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
loading = true
|
||||
container.catalogs.getValue(catalogId).tracks(opened).fold(
|
||||
onSuccess = { tracks = it; error = if (it.isEmpty()) "Aucune piste lisible" else null },
|
||||
onFailure = { error = it.message ?: "Contenu illisible" },
|
||||
)
|
||||
loading = false
|
||||
}
|
||||
|
||||
val selectedSize = remember(selection, tracks) {
|
||||
tracks.filter { it.id in selection }.sumOf { it.sizeBytes ?: 0L }
|
||||
}
|
||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = item?.title ?: "Pistes",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (tracks.isNotEmpty()) {
|
||||
TextButton(onClick = {
|
||||
selection = if (selection.size == tracks.size) emptySet()
|
||||
else tracks.mapTo(mutableSetOf()) { it.id }
|
||||
}) {
|
||||
Text(if (selection.size == tracks.size) "Aucune" else "Toutes")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (tracks.isNotEmpty()) {
|
||||
val total = tracks.sumOf { it.sizeBytes ?: 0L }
|
||||
Text(
|
||||
text = buildString {
|
||||
append("${tracks.size} piste(s)")
|
||||
if (total > 0) append(" · ${formatBytes(total)} au total")
|
||||
append(" · appuyez pour écouter en ligne")
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
error != null -> Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = error!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
else -> LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(tracks, key = { it.id }) { track ->
|
||||
TrackRow(
|
||||
track = track,
|
||||
playing = track.id == currentMediaId,
|
||||
selected = track.id in selection,
|
||||
onPreview = {
|
||||
// Un aperçu ne rejoint pas la bibliothèque : on le confie au
|
||||
// registre transitoire pour que la lecture sache le résoudre.
|
||||
container.library.preview(tracks)
|
||||
container.player.play(tracks, tracks.indexOf(track))
|
||||
},
|
||||
onToggle = {
|
||||
selection = if (track.id in selection) selection - track.id
|
||||
else selection + track.id
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.size(88.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SelectionBar(
|
||||
count = selection.size,
|
||||
actionLabel = if (selectedSize > 0) "Garder ${formatBytes(selectedSize)}" else "Garder",
|
||||
busy = adding,
|
||||
onClear = { selection = emptySet() },
|
||||
onConfirm = {
|
||||
adding = true
|
||||
scope.launch {
|
||||
val chosen = tracks.filter { it.id in selection }
|
||||
val added = container.imported.add(chosen)
|
||||
chosen.forEach(container.downloads::download)
|
||||
adding = false
|
||||
selection = emptySet()
|
||||
onMessage(
|
||||
if (added == 0) "Ces pistes sont déjà dans la bibliothèque"
|
||||
else "$added piste(s) ajoutées, téléchargement lancé"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(contentPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackRow(
|
||||
track: MediaEntry,
|
||||
playing: Boolean,
|
||||
selected: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = when {
|
||||
playing -> MaterialTheme.colorScheme.primaryContainer
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPreview),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
|
||||
contentDescription = if (playing) "En lecture" else "Écouter en ligne",
|
||||
tint = if (playing) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
track.durationMs.takeIf { it > 0 }?.let(::formatDuration),
|
||||
track.sizeBytes?.takeIf { it > 0 }?.let(::formatBytes),
|
||||
).joinToString(" · ").ifBlank { "Taille inconnue" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Checkbox(checked = selected, onCheckedChange = { onToggle() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package fr.tuxgyver.noxely.ui.browse
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Barre d'action des écrans de sélection : combien d'éléments, et que faire de la sélection. */
|
||||
@Composable
|
||||
fun SelectionBar(
|
||||
count: Int,
|
||||
actionLabel: String,
|
||||
busy: Boolean,
|
||||
onClear: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = count > 0,
|
||||
enter = slideInVertically { it },
|
||||
exit = slideOutVertically { it },
|
||||
modifier = modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$count sélectionné(s)",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onClear, enabled = !busy) { Text("Effacer") }
|
||||
Button(onClick = onConfirm, enabled = !busy) {
|
||||
if (busy) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Rounded.Download, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package fr.tuxgyver.noxely.ui.browse
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.noxely.AppContainer
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
import fr.tuxgyver.noxely.ui.components.MediaRow
|
||||
|
||||
/**
|
||||
* Parcours d'une source déjà présente dans la bibliothèque : on y choisit, un par un ou en lot,
|
||||
* ce qui doit être gardé sur l'appareil.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SourceBrowseScreen(
|
||||
container: AppContainer,
|
||||
source: SourceKind,
|
||||
currentMediaId: String?,
|
||||
onBack: () -> Unit,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||
|
||||
val ofSource = remember(entries, source) { entries.filter { it.source == source } }
|
||||
val collections = remember(ofSource) { ofSource.map { it.subtitle }.distinct().sorted() }
|
||||
|
||||
var collection by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||
|
||||
val visible = remember(ofSource, collection) {
|
||||
if (collection == null) ofSource else ofSource.filter { it.subtitle == collection }
|
||||
}
|
||||
val selectable = remember(visible, offlineStates) {
|
||||
visible.filter { it.isRemote && offlineStates[it.id]?.isComplete != true }
|
||||
}
|
||||
|
||||
// La barre de titre occupe déjà l'espace haut du Scaffold : la liste ne reprend que le bas.
|
||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(source.label) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (selectable.isNotEmpty()) {
|
||||
TextButton(onClick = {
|
||||
selection = if (selection.size == selectable.size) emptySet()
|
||||
else selectable.mapTo(mutableSetOf()) { it.id }
|
||||
}) {
|
||||
Text(if (selection.size == selectable.size) "Aucun" else "Tout")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (collections.size > 1) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = collection == null,
|
||||
onClick = { collection = null },
|
||||
label = { Text("Tout") },
|
||||
)
|
||||
collections.forEach { name ->
|
||||
FilterChip(
|
||||
selected = collection == name,
|
||||
onClick = { collection = name },
|
||||
label = { Text(name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Aucun contenu pour cette source. Ajoutez-en depuis l'écran Sources.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(visible, key = { it.id }) { entry ->
|
||||
val canSelect = entry.isRemote && offlineStates[entry.id]?.isComplete != true
|
||||
MediaRow(
|
||||
entry = entry,
|
||||
offline = offlineStates[entry.id],
|
||||
isCurrent = entry.id == currentMediaId,
|
||||
selected = if (canSelect) entry.id in selection else null,
|
||||
onClick = {
|
||||
if (canSelect) {
|
||||
selection = if (entry.id in selection) selection - entry.id
|
||||
else selection + entry.id
|
||||
} else {
|
||||
onPlay(visible, visible.indexOf(entry))
|
||||
}
|
||||
},
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.size(88.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SelectionBar(
|
||||
count = selection.size,
|
||||
actionLabel = "Télécharger",
|
||||
busy = false,
|
||||
onClear = { selection = emptySet() },
|
||||
onConfirm = {
|
||||
visible.filter { it.id in selection }.forEach(container.downloads::download)
|
||||
selection = emptySet()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(contentPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
package fr.tuxgyver.noxely.ui.radio
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
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
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
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.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
import fr.tuxgyver.noxely.radio.RADIO_GENRES
|
||||
import fr.tuxgyver.noxely.radio.RadioStation
|
||||
import fr.tuxgyver.noxely.radio.Recorder
|
||||
import fr.tuxgyver.noxely.radio.RecordingService
|
||||
import fr.tuxgyver.noxely.ui.components.Artwork
|
||||
import fr.tuxgyver.noxely.ui.components.formatBytes
|
||||
import fr.tuxgyver.noxely.ui.components.formatDuration
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Les stations les plus écoutées, à écouter en direct ou à enregistrer.
|
||||
*
|
||||
* L'enregistrement produit un fichier local qui rejoint la bibliothèque : il n'a donc rien à
|
||||
* télécharger ensuite, il est déjà hors connexion.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RadioScreen(
|
||||
container: AppContainer,
|
||||
currentMediaId: String?,
|
||||
onBack: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
onOpenWatches: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val status by Recorder.status.collectAsStateWithLifecycle()
|
||||
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var genre by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var nowPlaying by remember { mutableStateOf(emptyMap<String, String>()) }
|
||||
var scheduleFor by remember { mutableStateOf<RadioStation?>(null) }
|
||||
|
||||
fun load() {
|
||||
loading = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val outcome = if (query.isBlank()) container.radio.popular(genre)
|
||||
else container.radio.search(query)
|
||||
outcome.fold(
|
||||
onSuccess = { stations = it; if (it.isEmpty()) error = "Aucune station" },
|
||||
onFailure = { error = it.message ?: "Annuaire indisponible" },
|
||||
)
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(genre) { load() }
|
||||
|
||||
// Aucun annuaire n'expose le morceau en cours : il faut ouvrir chaque flux. On s'en tient
|
||||
// donc aux premières stations affichées, quatre à la fois, et le résultat est conservé.
|
||||
LaunchedEffect(stations) {
|
||||
nowPlaying = emptyMap()
|
||||
stations.take(PROBED_STATIONS).chunked(4).forEach { group ->
|
||||
val found = group.map { station ->
|
||||
async { station.id to container.radio.nowPlaying(station.streamUrl) }
|
||||
}.awaitAll()
|
||||
nowPlaying = nowPlaying + found.mapNotNull { (id, title) ->
|
||||
title?.let { id to it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||
|
||||
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")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (status.isRecording) {
|
||||
// La progression n'est publiée que tous les 256 Ko : le chronomètre se rafraîchit
|
||||
// donc de lui-même, sinon il semblerait figé entre deux paliers.
|
||||
var tick by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(status.startedAt) {
|
||||
while (true) {
|
||||
tick = System.currentTimeMillis()
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
RecordingBanner(
|
||||
station = status.station!!,
|
||||
title = status.currentTitle,
|
||||
waitingFor = status.waitingFor,
|
||||
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
|
||||
bytes = status.bytes,
|
||||
onStop = {
|
||||
RecordingService.stop(context)
|
||||
onMessage("Enregistrement versé dans la bibliothèque")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Rechercher une station") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { load() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = genre == null,
|
||||
onClick = { genre = null; query = "" },
|
||||
label = { Text("Les plus écoutées") },
|
||||
)
|
||||
RADIO_GENRES.forEach { option ->
|
||||
FilterChip(
|
||||
selected = genre == option,
|
||||
onClick = { genre = option; query = "" },
|
||||
label = { Text(option.replaceFirstChar(Char::uppercase)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
error != null -> Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = error!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
else -> LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(stations, key = { it.id }) { station ->
|
||||
StationRow(
|
||||
station = station,
|
||||
nowPlaying = nowPlaying[station.id],
|
||||
playing = station.asEntry().id == currentMediaId,
|
||||
recording = status.station?.id == station.id,
|
||||
onSchedule = { scheduleFor = station },
|
||||
onListen = {
|
||||
val entry = station.asEntry()
|
||||
container.library.preview(listOf(entry))
|
||||
container.player.play(listOf(entry), 0)
|
||||
},
|
||||
onRecord = {
|
||||
if (status.isRecording) {
|
||||
onMessage("Un enregistrement est déjà en cours")
|
||||
} else {
|
||||
RecordingService.start(context, station)
|
||||
onMessage("Enregistrement de ${station.name} démarré")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.size(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleFor?.let { station ->
|
||||
ScheduleDialog(
|
||||
station = station,
|
||||
onDismiss = { scheduleFor = null },
|
||||
onConfirm = { wanted ->
|
||||
RecordingService.start(context, station, whenTitleContains = wanted)
|
||||
onMessage("Noxely guette « $wanted » sur ${station.name}")
|
||||
scheduleFor = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Au-delà, le nombre de connexions simultanées ne se justifierait plus. */
|
||||
private const val PROBED_STATIONS = 12
|
||||
|
||||
/** Une station écoutée en direct est un média éphémère : pas de durée, pas de mise en cache. */
|
||||
private fun RadioStation.asEntry() = MediaEntry(
|
||||
id = "radio:$id",
|
||||
source = SourceKind.URL,
|
||||
kind = MediaKind.AUDIO,
|
||||
title = name,
|
||||
subtitle = subtitle.ifBlank { "Radio en direct" },
|
||||
uri = streamUrl,
|
||||
artworkUri = faviconUrl,
|
||||
)
|
||||
|
||||
/** Programme un enregistrement : il démarrera dès que la station annoncera ce titre. */
|
||||
@Composable
|
||||
private fun ScheduleDialog(
|
||||
station: RadioStation,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
var wanted by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Guetter un titre") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Noxely écoutera ${station.name} et déclenchera l'enregistrement dès " +
|
||||
"qu'un titre contenant ce texte sera annoncé, puis s'arrêtera à la fin du morceau.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
OutlinedTextField(
|
||||
value = wanted,
|
||||
onValueChange = { wanted = it },
|
||||
placeholder = { Text("Titre ou artiste") },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = wanted.isNotBlank(), onClick = { onConfirm(wanted.trim()) }) {
|
||||
Text("Guetter")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordingBanner(
|
||||
station: RadioStation,
|
||||
title: String?,
|
||||
waitingFor: String?,
|
||||
elapsedMs: Long,
|
||||
bytes: Long,
|
||||
onStop: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.FiberManualRecord,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFD32F2F),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = waitingFor?.let { "En attente de « $it »" } ?: title ?: station.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
title?.let { station.name },
|
||||
formatDuration(elapsedMs),
|
||||
formatBytes(bytes),
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Button(onClick = onStop) {
|
||||
Icon(Icons.Rounded.Stop, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text("Arrêter")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StationRow(
|
||||
station: RadioStation,
|
||||
nowPlaying: String?,
|
||||
playing: Boolean,
|
||||
recording: Boolean,
|
||||
onListen: () -> Unit,
|
||||
onRecord: () -> Unit,
|
||||
onSchedule: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (playing) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onListen),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
) {
|
||||
Artwork(
|
||||
uri = station.faviconUrl,
|
||||
title = station.name,
|
||||
kind = MediaKind.AUDIO,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = station.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = nowPlaying ?: station.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (nowPlaying != null) MaterialTheme.colorScheme.secondary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
|
||||
contentDescription = if (playing) "En écoute" else "Écouter en direct",
|
||||
tint = if (playing) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
IconButton(onClick = onSchedule) {
|
||||
Icon(
|
||||
Icons.Rounded.Alarm,
|
||||
contentDescription = "Enregistrer quand un titre passe",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRecord) {
|
||||
Icon(
|
||||
Icons.Rounded.FiberManualRecord,
|
||||
contentDescription = "Enregistrer cette station",
|
||||
tint = if (recording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package fr.tuxgyver.noxely.ui.server
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
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.ui.sources.UpdateCard
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val SETTINGS_TABS = listOf("🔌 Connexion", "🔄 Synchronisation", "📱 Application")
|
||||
|
||||
/**
|
||||
* Réglages, rangés par catégorie.
|
||||
*
|
||||
* L'écran mêlait la connexion au serveur, l'état des synchronisations et les affaires de
|
||||
* l'application elle-même. Trois sujets, trois onglets : on ne défile plus pour trouver.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServerScreen(
|
||||
container: AppContainer,
|
||||
onOpenAbout: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val config by container.serverSettings.config.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var url by remember { mutableStateOf("") }
|
||||
var token by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var probe by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(config.baseUrl, config.token) {
|
||||
if (url.isBlank()) url = config.baseUrl
|
||||
if (token.isBlank()) token = config.token
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.padding(horizontal = 16.dp).padding(top = 12.dp, bottom = 8.dp)) {
|
||||
Text("Réglages", style = MaterialTheme.typography.displaySmall)
|
||||
Text(
|
||||
text = "Le serveur cherche, guette les radios et enregistre. Le téléphone ne fait " +
|
||||
"que récupérer les fichiers prêts — c'est ce qui préserve la batterie.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
ScrollableTabRow(selectedTabIndex = tab, edgePadding = 16.dp) {
|
||||
SETTINGS_TABS.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = tab == index,
|
||||
onClick = { tab = index },
|
||||
text = { Text(label, style = MaterialTheme.typography.labelLarge) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
) {
|
||||
item { Spacer(Modifier.size(4.dp)) }
|
||||
|
||||
when (tab) {
|
||||
0 -> {
|
||||
item {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it; probe = null },
|
||||
label = { Text("Adresse du serveur") },
|
||||
placeholder = { Text("http://192.168.1.20:8710") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.size(10.dp))
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it; probe = null },
|
||||
label = { Text("Jeton") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.size(14.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(
|
||||
enabled = !busy && url.isNotBlank() && token.isNotBlank(),
|
||||
onClick = {
|
||||
busy = true
|
||||
probe = null
|
||||
scope.launch {
|
||||
container.sync.probe(url.trim(), token.trim()).fold(
|
||||
onSuccess = { name ->
|
||||
container.serverSettings.update {
|
||||
it.copy(baseUrl = url.trim(), token = token.trim())
|
||||
}
|
||||
probe = "Connecté à $name"
|
||||
},
|
||||
onFailure = { probe = it.message ?: "Échec de connexion" },
|
||||
)
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
) { Text("Tester et enregistrer") }
|
||||
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
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") }
|
||||
}
|
||||
}
|
||||
probe?.let {
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (it.startsWith("Connecté")) MaterialTheme.colorScheme.secondary
|
||||
else MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
1 -> {
|
||||
item {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.CloudSync, contentDescription = null)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text("Synchronisation automatique", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = "Toutes les six heures, en Wi-Fi non facturé et batterie suffisante.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (config.lastSyncAt > 0) {
|
||||
Text(
|
||||
text = "Dernière : ${
|
||||
SimpleDateFormat("d MMM HH:mm", Locale.FRANCE)
|
||||
.format(Date(config.lastSyncAt))
|
||||
} — ${config.lastResult}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
else -> {
|
||||
item { UpdateCard(container) }
|
||||
|
||||
item {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Info, contentDescription = null)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("À propos", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "Version ${BuildConfig.VERSION_NAME}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onOpenAbout) { Text("Ouvrir") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.size(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
package fr.tuxgyver.noxely.ui.sources
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||
import androidx.compose.material.icons.rounded.Headphones
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||
import androidx.compose.material.icons.rounded.Public
|
||||
import androidx.compose.material.icons.rounded.Radio
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.Podcasts
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.Smartphone
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.noxely.AppContainer
|
||||
import fr.tuxgyver.noxely.model.SourceKind
|
||||
import fr.tuxgyver.noxely.sources.CatalogId
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SourcesScreen(
|
||||
container: AppContainer,
|
||||
contentPadding: PaddingValues,
|
||||
onMessage: (String) -> Unit,
|
||||
onBrowseSource: (SourceKind) -> Unit,
|
||||
onBrowseCatalog: (CatalogId) -> Unit,
|
||||
onBrowseRadio: () -> Unit,
|
||||
onOpenAbout: () -> Unit,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
var sheet by remember { mutableStateOf<ImportKind?>(null) }
|
||||
|
||||
val deviceCount = entries.count { it.source == SourceKind.LOCAL }
|
||||
val linkCount = entries.count { it.source == SourceKind.URL }
|
||||
val podcastCount = entries.count { it.source == SourceKind.PODCAST }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(top = 12.dp)) {
|
||||
Text("Sources", style = MaterialTheme.typography.displaySmall)
|
||||
Text(
|
||||
"D'où vient le contenu que vous gardez hors connexion.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SourceCard(
|
||||
icon = Icons.Rounded.Smartphone,
|
||||
title = "Appareil",
|
||||
status = "$deviceCount éléments",
|
||||
description = "Musique et vidéos déjà présentes sur le téléphone. Toujours disponibles.",
|
||||
actionLabel = "Actualiser",
|
||||
actionIcon = Icons.Rounded.Refresh,
|
||||
onAction = { container.library.refreshLocal() },
|
||||
onOpen = { onBrowseSource(SourceKind.LOCAL) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SourceCard(
|
||||
icon = Icons.Rounded.Link,
|
||||
title = "Lien direct",
|
||||
status = "$linkCount éléments",
|
||||
description = "Une URL vers un fichier audio ou vidéo. Le fichier est ensuite copié dans la cachette.",
|
||||
actionLabel = "Ajouter un lien",
|
||||
onAction = { sheet = ImportKind.URL },
|
||||
onOpen = { onBrowseSource(SourceKind.URL) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SourceCard(
|
||||
icon = Icons.Rounded.Podcasts,
|
||||
title = "Podcast",
|
||||
status = "$podcastCount épisodes",
|
||||
description = "Un flux RSS. Les épisodes deviennent téléchargeables un par un.",
|
||||
actionLabel = "Ajouter un flux",
|
||||
onAction = { sheet = ImportKind.RSS },
|
||||
onOpen = { onBrowseSource(SourceKind.PODCAST) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val recordings = entries.count { it.source == SourceKind.RECORDING }
|
||||
SourceCard(
|
||||
icon = Icons.Rounded.Radio,
|
||||
title = "Radio",
|
||||
status = if (recordings > 0) "$recordings enregistrement(s)" else "Direct et enregistrement",
|
||||
description = "Les stations les plus écoutées. À suivre en direct, ou à enregistrer pour l'écouter plus tard.",
|
||||
actionLabel = "Ouvrir la radio",
|
||||
onAction = onBrowseRadio,
|
||||
onOpen = onBrowseRadio,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Rechercher, choisir, emporter. Aucun compte requis.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
items(CatalogId.entries) { catalog ->
|
||||
SourceCard(
|
||||
icon = when (catalog) {
|
||||
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
|
||||
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
|
||||
CatalogId.UNDERGROUND -> Icons.Rounded.Headphones
|
||||
else -> Icons.Rounded.Public
|
||||
},
|
||||
title = catalog.title,
|
||||
status = catalog.license,
|
||||
description = catalog.description,
|
||||
actionLabel = "Parcourir",
|
||||
actionIcon = Icons.Rounded.Search,
|
||||
onAction = { onBrowseCatalog(catalog) },
|
||||
onOpen = { onBrowseCatalog(catalog) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
UpdateCard(container)
|
||||
}
|
||||
|
||||
item {
|
||||
SourceCard(
|
||||
icon = Icons.Rounded.Info,
|
||||
title = "À propos",
|
||||
status = "Version ${fr.tuxgyver.noxely.BuildConfig.VERSION_NAME}",
|
||||
description = "Ce que fait Noxely, ce qu'elle garantit, avec quoi elle est faite et qui en répond.",
|
||||
actionLabel = "Ouvrir",
|
||||
onAction = onOpenAbout,
|
||||
onOpen = onOpenAbout,
|
||||
)
|
||||
}
|
||||
|
||||
unavailableSourceCards(
|
||||
listOf(
|
||||
UnavailableSource(
|
||||
"Spotify",
|
||||
"Le SDK Spotify ne donne pas accès au flux audio : la lecture hors connexion n'existe que dans l'application Spotify. Un podcast diffusé aussi en RSS public peut en revanche être ajouté ci-dessus.",
|
||||
),
|
||||
UnavailableSource(
|
||||
"YouTube",
|
||||
"Les conditions d'utilisation interdisent d'extraire ou de stocker les flux. Le hors-ligne passe par YouTube Premium, dans leur application.",
|
||||
),
|
||||
UnavailableSource(
|
||||
"Google Drive / Photos",
|
||||
"Techniquement possible pour vos propres fichiers, mais cela demande un identifiant OAuth Google propre à votre compte développeur, qui n'est pas fourni ici.",
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val current = sheet
|
||||
if (current != null) {
|
||||
ImportSheet(
|
||||
kind = current,
|
||||
container = container,
|
||||
onDismiss = { sheet = null },
|
||||
onMessage = onMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class UnavailableSource(val title: String, val explanation: String)
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.unavailableSourceCards(sources: List<UnavailableSource>) {
|
||||
sources.forEach { source ->
|
||||
item {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(16.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column {
|
||||
Text(source.title, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
source.explanation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceCard(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
status: String,
|
||||
description: String,
|
||||
actionLabel: String,
|
||||
actionIcon: ImageVector? = null,
|
||||
onAction: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpen),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
status,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = "Parcourir",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Button(onClick = onAction) {
|
||||
if (actionIcon != null) {
|
||||
Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class ImportKind(val title: String, val hint: String) {
|
||||
URL("Ajouter un lien direct", "https://exemple.org/morceau.mp3"),
|
||||
RSS("Ajouter un flux podcast", "https://exemple.org/podcast.xml"),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImportSheet(
|
||||
kind: ImportKind,
|
||||
container: AppContainer,
|
||||
onDismiss: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var url by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var mode by remember { mutableStateOf(kind) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 24.dp)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Text(mode.title, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ImportKind.entries.forEach { option ->
|
||||
FilterChip(
|
||||
selected = mode == option,
|
||||
onClick = { mode = option; error = null },
|
||||
label = { Text(if (option == ImportKind.URL) "Lien direct" else "Flux podcast") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(16.dp))
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it; error = null },
|
||||
placeholder = { Text(mode.hint) },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(20.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(onClick = onDismiss, enabled = !busy) { Text("Annuler") }
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Button(
|
||||
enabled = !busy && url.isNotBlank(),
|
||||
onClick = {
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val outcome = runImport(container, mode, url)
|
||||
busy = false
|
||||
outcome.fold(
|
||||
onSuccess = { message ->
|
||||
onMessage(message)
|
||||
onDismiss()
|
||||
},
|
||||
onFailure = { error = it.message ?: "Import impossible" },
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (busy) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
Text("Importer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runImport(
|
||||
container: AppContainer,
|
||||
kind: ImportKind,
|
||||
url: String,
|
||||
): Result<String> = when (kind) {
|
||||
ImportKind.URL -> container.urlImporter.import(url).mapCatching { entry ->
|
||||
val added = container.imported.add(listOf(entry))
|
||||
if (added == 0) "« ${entry.title} » est déjà dans la bibliothèque"
|
||||
else "« ${entry.title} » ajouté"
|
||||
}
|
||||
|
||||
ImportKind.RSS -> container.rssImporter.import(url).mapCatching { feed ->
|
||||
val added = container.imported.add(feed.episodes)
|
||||
if (added == 0) "${feed.title} : aucun nouvel épisode"
|
||||
else "${feed.title} : $added épisode(s) ajouté(s)"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user