2 Commits
Author SHA1 Message Date
tuxgyver adc6fc0486 Guet sur toutes les stations, veille de fond et pochettes (1.7.0)
Guetter un morceau supposait de savoir sur quelle station il passerait. On le
nomme désormais sans plus : les quarante stations les plus écoutées sont
balayées quatre à la fois, et l'enregistrement part dès que l'une le diffuse.
Un écran sépare ce qu'on attend de ce qu'on a capté.

Reste que la tâche périodique du système ne descend pas sous le quart d'heure,
quand un morceau dure trois à quatre minutes : elle ne peut qu'en manquer. Une
veille active, à enclencher, balaie toutes les trois minutes en service de
premier plan, et s'arrête d'elle-même une fois le guet vide. L'écran expose ce
compromis plutôt que de laisser croire à une surveillance continue gratuite.

Les titres sans pochette en reçoivent une, cherchée sur leur nom. Le nom est
nettoyé d'abord : un enregistrement radio traîne celui de sa station, qui
fausserait la recherche. Le bouton d'actualisation de l'écran Hors ligne relance
le scan et complète ce qui manque.

L'écran de lecture dit enfin d'où vient ce qu'on écoute : playlist, source,
taille, date.

Un guet ne s'enregistrait pas : le champ était vidé avant que la coroutine n'en
lise la valeur. Elle est capturée avant l'effacement.
2026-09-13 14:22:36 +02:00
tuxgyver 8d96a9fdde Cachette devient Noxely, et gagne une page À propos (1.6.0)
Le nom change, et pas seulement à l'écran : identifiant d'application, paquet
Kotlin, dépôt et dossier d'enregistrements suivent, pour qu'il ne subsiste pas
deux noms selon l'endroit où l'on regarde.

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

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

La page À propos dit enfin ce que l'application fait, ce qu'elle garantit, avec
quoi elle est construite et qui en répond. Sa version vient du manifeste et son
journal du CHANGELOG du dépôt : ni l'un ni l'autre ne peut dériver. La licence
n'étant pas déclarée, la pastille le signale en rouge au lieu d'en inventer une.

Le .gitignore filtre désormais *.apk, et non un motif au nom du projet : c'est
ce motif nommé qui, au renommage, avait laissé entrer 95 Mo d'anciens APK.
2026-09-13 14:00:34 +02:00
75 changed files with 1653 additions and 271 deletions

No files matched your search

+3 -1
View File
@@ -10,5 +10,7 @@ captures/
keystore.properties
*.jks
.gitea-token
Cachette-*.apk
# Motif large : un motif au nom du projet a laissé passer 95 Mo lors du renommage.
*.apk
update-token.properties
app/src/main/assets/CHANGELOG.md
+56 -3
View File
@@ -1,10 +1,63 @@
# Changelog
Toutes les évolutions notables de Cachette sont documentées ici.
Toutes les évolutions notables de Noxely sont documentées ici.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
et le versionnage [SemVer](https://semver.org/lang/fr/).
## [1.7.0] — 2026-09-13
### Ajouté
- **Guet sur l'ensemble des stations.** On nomme un morceau ou un artiste, sans désigner de
station : Noxely balaie les quarante stations les plus écoutées, quatre à la fois, et déclenche
l'enregistrement dès qu'une le diffuse. Un écran dédié sépare ce qui est attendu de ce qui a été
capté, avec le nombre de balayages et l'heure du dernier.
- **Veille en arrière-plan.** Deux mécanismes, parce qu'un seul ne suffit pas : une tâche
périodique tourne en permanence au quart d'heure — le minimum qu'autorise Android — et une
veille active, à enclencher, balaie toutes les trois minutes, écran éteint compris. Un morceau
durant trois à quatre minutes, la tâche périodique en manque forcément ; l'écran le dit au lieu
de le taire. La veille s'arrête d'elle-même quand plus rien n'est guetté.
- **Pochettes automatiques.** Les titres sans image en reçoivent une, cherchée sur leur nom via la
recherche publique d'iTunes, sans clé. Le nom est nettoyé au préalable : un enregistrement radio
traîne celui de sa station, qui brouillerait la requête.
- **Bouton d'actualisation** de l'écran Hors ligne : relance le scan de l'appareil et complète les
pochettes manquantes, par lots bornés pour ne pas marteler le service.
- L'écran de lecture affiche la provenance du morceau — nom de la playlist s'il en vient —, sa
source, sa taille et sa date.
### Corrigé
- Un morceau guetté n'était pas enregistré : le champ de saisie était vidé avant que la coroutine
n'en lise la valeur. Elle est désormais capturée avant l'effacement.
## [1.6.0] — 2026-09-13
### Changé
- **L'application s'appelle désormais Noxely.** Le nom affiché, l'identifiant d'application
(`fr.tuxgyver.noxely`), le paquet Kotlin, le dépôt et le dossier d'enregistrements
(`Music/Noxely`) suivent tous.
⚠️ **Android considère le nouvel identifiant comme une application distincte.** Une
installation en 1.5.0 ne se met donc pas à jour toute seule : il faut la désinstaller, puis
installer Noxely. Les enregistrements déjà rangés dans `Music/Cachette` ne sont pas perdus —
ils sont dans le stockage partagé, restent lisibles par tout lecteur, et le scan continue de
les écarter pour ne pas les afficher en double.
- Nouvelle icône. Le visuel fourni est détouré de son fond, décliné aux cinq densités et posé
sur une couleur échantillonnée dans l'image : sans cela, le masque du système aurait découpé
une seconde fois un badge qui l'est déjà, en laissant du blanc sur les bords.
### Ajouté
- Page « À propos », accessible depuis l'écran Sources : ce que fait l'application, ce qu'elle
garantit, avec quoi elle est construite, ce qui a changé et qui en répond. La version est lue
du manifeste et le journal du CHANGELOG du dépôt, recopié à la compilation — l'un et l'autre
ne peuvent donc pas diverger de la réalité.
- La pastille de licence signale en rouge qu'aucune licence n'est déclarée dans le dépôt, plutôt
que d'en afficher une par défaut.
## [1.5.0] — 2026-09-13
### Ajouté
@@ -25,7 +78,7 @@ et le versionnage [SemVer](https://semver.org/lang/fr/).
### Modifié
- Les enregistrements sont désormais écrits dans `Music/Cachette` et déclarés dans MediaStore
- Les enregistrements sont désormais écrits dans `Music/Noxely` et déclarés dans MediaStore
(Android 10 et plus) : ils deviennent visibles des autres lecteurs et survivent à la
désinstallation. Le scan local ignore ce dossier, faute de quoi chaque enregistrement
apparaîtrait deux fois.
@@ -109,7 +162,7 @@ et le versionnage [SemVer](https://semver.org/lang/fr/).
### Ajouté
- Mise à jour intégrée : Cachette surveille les releases du dépôt, télécharge l'APK en Wi-Fi
- Mise à jour intégrée : Noxely surveille les releases du dépôt, télécharge l'APK en Wi-Fi
et propose l'installation. Vérification quotidienne en tâche de fond, et à la demande depuis
l'écran Sources, qui affiche aussi la version installée.
- La taille approximative de chaque résultat Internet Archive est affichée avant sélection : un
+1 -1
View File
@@ -15,7 +15,7 @@ DEBUG_APK := app/build/outputs/apk/debug/app-debug.apk
# app-release-unsigned.apk) : on liste le répertoire au lieu de deviner.
RELEASE_DIR := app/build/outputs/apk/release
SIGNED_APK := $(RELEASE_DIR)/app-release.apk
OUTPUT_APK := Cachette-$(VERSION).apk
OUTPUT_APK := Noxely-$(VERSION).apk
# --- Publication -----------------------------------------------------------
# Déléguée au script de BorisSkill, qui sait déjà tout faire : résolution du
+20 -9
View File
@@ -1,4 +1,4 @@
# 🎧 Cachette
# 🎧 Noxely
> Mettez la musique et les vidéos **de côté** pendant que vous avez du réseau. Écoutez-les quand vous n'en avez plus.
@@ -6,10 +6,10 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org)
[![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose)
[![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3)
[![Version](https://img.shields.io/badge/Version-1.5.0-6B4EFF)](VERSION)
[![Version](https://img.shields.io/badge/Version-1.7.0-6B4EFF)](VERSION)
[![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche)
Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
Noxely est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
forfaits épuisés. On choisit ce qu'on emporte, l'application le télécharge, et tout reste lisible
sans la moindre connexion.
@@ -44,8 +44,11 @@ sans la moindre connexion.
| 🔁 **Reprise automatique** | Réseau coupé : mise en attente et reprise. Échec serveur : relance seule après 5 s, 15 s, 45 s |
| 📻 **Radio** | Les stations les plus écoutées, en direct — et enregistrables d'un appui |
| 🏷️ **Titrage automatique** | L'enregistrement prend le nom du morceau annoncé par la station |
| ℹ️ **Page « À propos »** | Fonctions, garanties, pile technique, journal et auteur |
| 👀 **Titre en cours** | Ce que diffuse chaque station, lu dans son flux |
| ⏰ **Guet d'un titre** | L'enregistrement se déclenche quand le morceau voulu passe, et s'arrête à sa fin |
| 📡 **Guet sur toutes les stations** | Nommez un morceau : Noxely balaie l'annuaire jusqu'à le trouver |
| 🖼️ **Pochettes automatiques** | Les titres sans image en reçoivent une, cherchée sur leur nom |
| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation |
---
@@ -71,7 +74,7 @@ sans la moindre connexion.
| **ccMixter** | La recherche fonctionne, mais les fichiers ne sont servis qu'avec un en-tête `Referer` — que Media3 ne permet pas de poser par téléchargement. |
| **Radio Browser** | Flux radio continus : rien à mettre en cache. |
### ⚖️ Ce que Cachette ne fait pas — et pourquoi
### ⚖️ Ce que Noxely ne fait pas — et pourquoi
| Service | Pourquoi |
|---|---|
@@ -194,16 +197,24 @@ sortantes sont celles des URL que vous ajoutez et des catalogues que vous interr
---
> ⚠️ **Changement de nom en 1.6.0.** L'application s'appelait Cachette et son identifiant était
> `fr.tuxgyver.cachette`. Android traite le nouvel identifiant `fr.tuxgyver.noxely` comme une
> application distincte : une installation en 1.5.0 ne se met pas à jour toute seule, il faut la
> désinstaller puis installer Noxely. Les enregistrements déjà rangés dans `Music/Cachette`
> survivent — ils sont dans le stockage partagé — et restent lisibles par tout lecteur.
---
## 📼 Où vont les enregistrements
À partir d'Android 10, dans `Music/Cachette/`, déclarés dans MediaStore : ils sont donc visibles
À partir d'Android 10, dans `Music/Noxely/`, déclarés dans MediaStore : ils sont donc visibles
de tous les lecteurs de l'appareil et survivent à la désinstallation. En deçà, ils restent dans le
dossier privé de l'application, MediaStore y réclamant une permission de stockage dont Cachette n'a
dossier privé de l'application, MediaStore y réclamant une permission de stockage dont Noxely n'a
pas besoin par ailleurs.
Le nom du fichier porte le titre du morceau — et pas seulement la colonne `TITLE` : en publiant le
fichier, MediaStore le relit et réécrit ce champ à partir du nom. Le scan local, lui, ignore
`Music/Cachette` pour ne pas afficher deux fois ce que la bibliothèque connaît déjà.
`Music/Noxely` pour ne pas afficher deux fois ce que la bibliothèque connaît déjà.
Chaque enregistrement se supprime individuellement depuis l'écran **Hors ligne** : fichier, entrée
MediaStore et référence en bibliothèque partent ensemble. « Tout libérer » ne les touche pas.
@@ -212,7 +223,7 @@ MediaStore et référence en bibliothèque partent ensemble. « Tout libérer »
## 🆕 Mises à jour
Cachette étant distribuée hors magasin, elle surveille elle-même les releases du dépôt.
Noxely étant distribuée hors magasin, elle surveille elle-même les releases du dépôt.
```
Release publiée ──► vérification quotidienne (WorkManager, Wi-Fi)
@@ -245,7 +256,7 @@ giteaUpdateToken=…
```
app/src/main/java/fr/tuxgyver/cachette/
├── CachetteApp.kt Application + conteneur de dépendances (assemblé à la main)
├── NoxelyApp.kt Application + conteneur de dépendances (assemblé à la main)
├── MainActivity.kt
├── model/ MediaEntry, MediaKind, SourceKind
├── library/ MediaStore, persistance des imports, bibliothèque unifiée
+1 -1
View File
@@ -1 +1 @@
1.5.0
1.7.0
+10 -3
View File
@@ -8,11 +8,11 @@ plugins {
}
android {
namespace = "fr.tuxgyver.cachette"
namespace = "fr.tuxgyver.noxely"
compileSdk = 36
defaultConfig {
applicationId = "fr.tuxgyver.cachette"
applicationId = "fr.tuxgyver.noxely"
minSdk = 26
targetSdk = 35
@@ -33,7 +33,7 @@ android {
""
}
buildConfigField("String", "GITEA_UPDATE_TOKEN", "\"$updateToken\"")
buildConfigField("String", "UPDATE_API", "\"https://git.tuxtech.fr/api/v1/repos/tuxgyver/Cachette\"")
buildConfigField("String", "UPDATE_API", "\"https://git.tuxtech.fr/api/v1/repos/tuxgyver/Noxely\"")
}
buildTypes {
@@ -77,6 +77,13 @@ android {
}
}
/** Le journal affiché dans « À propos » est celui du dépôt, pas une copie qui dériverait. */
val copyChangelog = tasks.register<Copy>("copyChangelogToAssets") {
from(rootProject.file("CHANGELOG.md"))
into(layout.projectDirectory.dir("src/main/assets"))
}
tasks.named("preBuild") { dependsOn(copyChangelog) }
dependencies {
implementation(platform("androidx.compose:compose-bom:2025.10.00"))
implementation("androidx.compose.ui:ui")
+9 -4
View File
@@ -18,7 +18,7 @@
android:maxSdkVersion="32" />
<application
android:name=".CachetteApp"
android:name=".NoxelyApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
@@ -26,13 +26,13 @@
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Cachette">
android:theme="@style/Theme.Noxely">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Cachette"
android:theme="@style/Theme.Noxely"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -50,7 +50,7 @@
</service>
<service
android:name=".offline.CachetteDownloadService"
android:name=".offline.NoxelyDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync">
<intent-filter>
@@ -64,6 +64,11 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".radio.WatchService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette
package fr.tuxgyver.noxely
import android.Manifest
import android.content.pm.PackageManager
@@ -9,8 +9,8 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import fr.tuxgyver.cachette.ui.CachetteRoot
import fr.tuxgyver.cachette.ui.theme.CachetteTheme
import fr.tuxgyver.noxely.ui.NoxelyRoot
import fr.tuxgyver.noxely.ui.theme.NoxelyTheme
class MainActivity : ComponentActivity() {
@@ -21,20 +21,20 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val container = (application as CachetteApp).container
val container = (application as NoxelyApp).container
container.player.connect()
askForNotificationsIfNeeded()
setContent {
CachetteTheme {
CachetteRoot(container)
NoxelyTheme {
NoxelyRoot(container)
}
}
}
override fun onDestroy() {
if (isFinishing) {
(application as CachetteApp).container.player.release()
(application as NoxelyApp).container.player.release()
}
super.onDestroy()
}
@@ -1,25 +1,28 @@
package fr.tuxgyver.cachette
package fr.tuxgyver.noxely
import android.app.Application
import android.content.Context
import fr.tuxgyver.cachette.library.ImportedStore
import fr.tuxgyver.cachette.library.LibraryRepository
import fr.tuxgyver.cachette.library.LocalMediaScanner
import fr.tuxgyver.cachette.library.PlaylistStore
import fr.tuxgyver.cachette.offline.DownloadTracker
import fr.tuxgyver.cachette.offline.OfflineModule
import fr.tuxgyver.cachette.playback.PlayerController
import fr.tuxgyver.cachette.radio.RadioDirectory
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
import fr.tuxgyver.cachette.sources.Catalog
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.sources.CatalogItem
import fr.tuxgyver.cachette.sources.WikimediaCatalog
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
import fr.tuxgyver.cachette.sources.RssImporter
import fr.tuxgyver.cachette.sources.UrlImporter
import fr.tuxgyver.cachette.update.UpdateCheckWorker
import fr.tuxgyver.cachette.update.UpdateRepository
import fr.tuxgyver.noxely.library.ArtworkFinder
import fr.tuxgyver.noxely.library.ImportedStore
import fr.tuxgyver.noxely.library.LibraryRepository
import fr.tuxgyver.noxely.library.LocalMediaScanner
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.update.UpdateCheckWorker
import fr.tuxgyver.noxely.update.UpdateRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -40,8 +43,11 @@ class AppContainer(context: Context) {
val imported = ImportedStore(context, scope)
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
val playlists = PlaylistStore(context, scope)
val artwork = ArtworkFinder()
val radio = RadioDirectory()
val watches = WatchStore(context, scope)
val watchScanner = WatchScanner(radio, watches)
val urlImporter = UrlImporter()
val rssImporter = RssImporter()
@@ -67,7 +73,7 @@ class AppContainer(context: Context) {
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
}
class CachetteApp : Application() {
class NoxelyApp : Application() {
lateinit var container: AppContainer
private set
@@ -76,5 +82,6 @@ class CachetteApp : Application() {
super.onCreate()
container = AppContainer(this)
UpdateCheckWorker.schedule(this)
WatchScanner.schedule(this)
}
}
@@ -0,0 +1,61 @@
package fr.tuxgyver.noxely.library
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.URLEncoder
/**
* Cherche une pochette pour un titre, via la recherche publique d'iTunes (sans clé).
*
* Un enregistrement radio ou un fichier importé n'a pas d'image : celle-ci est déduite du titre
* et de l'interprète. Rien n'est téléchargé ici — seule l'adresse est retenue, et l'affichage
* s'en charge, ce qui évite de stocker des images pour des médias qu'on ne regardera pas.
*/
class ArtworkFinder {
private val json = Json { ignoreUnknownKeys = true }
suspend fun find(entry: MediaEntry): String? = withContext(Dispatchers.IO) {
val term = buildString {
append(entry.title.cleaned())
val artist = entry.subtitle.cleaned()
if (artist.isNotBlank() && !entry.title.contains(artist, ignoreCase = true)) {
append(' ').append(artist)
}
}.trim().take(120)
if (term.length < 3) return@withContext null
runCatching {
val url = "https://itunes.apple.com/search" +
"?term=${URLEncoder.encode(term, "UTF-8")}&entity=song&limit=1"
val connection = openConnection(url, readTimeoutMs = 15_000)
val body = try {
if (connection.responseCode !in 200..299) return@runCatching null
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
val first = (json.parseToJsonElement(body).jsonObject["results"] as? JsonArray)
?.firstOrNull() as? JsonObject ?: return@runCatching null
// La vignette est servie en 100 px ; l'adresse accepte une taille supérieure.
first["artworkUrl100"]?.jsonPrimitive?.content?.replace("100x100", "600x600")
}.getOrNull()
}
/** Les enregistrements radio traînent le nom de la station : il brouille la recherche. */
private fun String.cleaned(): String = this
.substringBefore(" - Classic Vinyl on ")
.replace(Regex("\\s+on\\s+\\S+\\.(com|fr|net|org)", RegexOption.IGNORE_CASE), "")
.replace(Regex("^\\d{1,3}\\s*[-.]\\s*"), "")
.replace(Regex("\\.(mp3|aac|ogg|m4a|flac)$", RegexOption.IGNORE_CASE), "")
.trim()
}
@@ -1,7 +1,7 @@
package fr.tuxgyver.cachette.library
package fr.tuxgyver.noxely.library
import android.content.Context
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -45,6 +45,15 @@ class ImportedStore(context: Context, private val scope: CoroutineScope) {
fresh.size
}
/** Renseigne la pochette trouvée pour une entrée, sans toucher au reste. */
suspend fun setArtwork(id: String, artworkUri: String) = mutex.withLock {
val merged = _entries.value.map { if (it.id == id) it.copy(artworkUri = artworkUri) else it }
if (merged != _entries.value) {
_entries.value = merged
write(merged)
}
}
suspend fun remove(id: String) = mutex.withLock {
val merged = _entries.value.filterNot { it.id == id }
if (merged.size != _entries.value.size) {
@@ -1,6 +1,6 @@
package fr.tuxgyver.cachette.library
package fr.tuxgyver.noxely.library
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.library
package fr.tuxgyver.noxely.library
import android.content.ContentUris
import android.content.Context
@@ -8,9 +8,9 @@ import androidx.core.net.toUri
import android.os.Build
import android.provider.MediaStore
import androidx.core.content.ContextCompat
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
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
@@ -124,13 +124,16 @@ class LocalMediaScanner(private val context: Context) {
}
/**
* Musique et podcasts, hors enregistrements de Cachette : ceux-ci sont déjà décrits par la
* Musique et podcasts, hors enregistrements de Noxely : ceux-ci sont déjà décrits par la
* bibliothèque, et les relire ici les afficherait en double.
*/
private fun audioSelection(): String {
val base = "(${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0)"
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"$base AND ${MediaStore.Audio.Media.RELATIVE_PATH} NOT LIKE 'Music/Cachette%'"
// L'ancien dossier reste exclu : les enregistrements d'avant le changement de nom
// sont toujours décrits par la bibliothèque et apparaîtraient sinon en double.
"$base AND ${MediaStore.Audio.Media.RELATIVE_PATH} NOT LIKE 'Music/Noxely%'" +
" AND ${MediaStore.Audio.Media.RELATIVE_PATH} NOT LIKE 'Music/Cachette%'"
} else {
base
}
@@ -1,7 +1,7 @@
package fr.tuxgyver.cachette.library
package fr.tuxgyver.noxely.library
import android.content.Context
import fr.tuxgyver.cachette.model.Playlist
import fr.tuxgyver.noxely.model.Playlist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.model
package fr.tuxgyver.noxely.model
import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.model
package fr.tuxgyver.noxely.model
import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.offline
package fr.tuxgyver.noxely.offline
import android.content.Context
import androidx.annotation.OptIn
@@ -8,7 +8,7 @@ import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -110,7 +110,7 @@ class DownloadTracker(
// Ré-ajouter la même requête relance un téléchargement en échec.
DownloadService.sendAddDownload(
context,
CachetteDownloadService::class.java,
NoxelyDownloadService::class.java,
download.request,
false,
)
@@ -129,15 +129,15 @@ class DownloadTracker(
val request = DownloadRequest.Builder(entry.id, entry.uri.toUri())
.setData(entry.title.toByteArray())
.build()
DownloadService.sendAddDownload(context, CachetteDownloadService::class.java, request, false)
DownloadService.sendAddDownload(context, NoxelyDownloadService::class.java, request, false)
}
fun remove(id: String) {
DownloadService.sendRemoveDownload(context, CachetteDownloadService::class.java, id, false)
DownloadService.sendRemoveDownload(context, NoxelyDownloadService::class.java, id, false)
}
fun removeAll() {
DownloadService.sendRemoveAllDownloads(context, CachetteDownloadService::class.java, false)
DownloadService.sendRemoveAllDownloads(context, NoxelyDownloadService::class.java, false)
}
private suspend fun reload() = withContext(Dispatchers.IO) {
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.offline
package fr.tuxgyver.noxely.offline
import android.app.Notification
import androidx.annotation.OptIn
@@ -9,15 +9,15 @@ import androidx.media3.exoplayer.offline.DownloadService
import androidx.media3.exoplayer.scheduler.Scheduler
import androidx.media3.exoplayer.workmanager.WorkManagerScheduler
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import fr.tuxgyver.cachette.CachetteApp
import fr.tuxgyver.cachette.R
import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.noxely.R
/**
* Service de mise en cache. Il tourne en premier plan pendant les téléchargements et délègue la
* reprise après coupure réseau ou redémarrage à WorkManager.
*/
@OptIn(UnstableApi::class)
class CachetteDownloadService : DownloadService(
class NoxelyDownloadService : DownloadService(
NOTIFICATION_ID,
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
CHANNEL_ID,
@@ -29,7 +29,7 @@ class CachetteDownloadService : DownloadService(
}
override fun getDownloadManager(): DownloadManager =
(application as CachetteApp).container.offline.downloadManager
(application as NoxelyApp).container.offline.downloadManager
override fun getScheduler(): Scheduler = WorkManagerScheduler(this, WORK_NAME)
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.offline
package fr.tuxgyver.noxely.offline
import android.content.Context
import androidx.annotation.OptIn
@@ -31,7 +31,7 @@ class OfflineModule(private val context: Context) {
private val httpDataSourceFactory: DataSource.Factory by lazy {
DefaultHttpDataSource.Factory()
.setUserAgent("Cachette/1.0 (Android)")
.setUserAgent("Noxely/1.0 (Android)")
.setAllowCrossProtocolRedirects(true)
.setConnectTimeoutMs(20_000)
.setReadTimeoutMs(30_000)
@@ -1,9 +1,9 @@
package fr.tuxgyver.cachette.playback
package fr.tuxgyver.noxely.playback
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
/**
* Les `MediaItem` transmis par un contrôleur perdent leur URI en chemin : c'est le service qui la
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.playback
package fr.tuxgyver.noxely.playback
import android.app.PendingIntent
import android.content.Intent
@@ -13,8 +13,8 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import fr.tuxgyver.cachette.CachetteApp
import fr.tuxgyver.cachette.MainActivity
import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.noxely.MainActivity
/** Lecture audio et vidéo en arrière-plan, avec notification et commandes système. */
@OptIn(UnstableApi::class)
@@ -24,7 +24,7 @@ class PlaybackService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
val container = (application as CachetteApp).container
val container = (application as NoxelyApp).container
val player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(container.offline.playbackDataSourceFactory()))
@@ -76,7 +76,7 @@ class PlaybackService : MediaSessionService() {
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> {
val library = (application as CachetteApp).container.library
val library = (application as NoxelyApp).container.library
val resolved = mediaItems.mapTo(mutableListOf()) { item ->
library.entry(item.mediaId)?.toMediaItem() ?: item
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.playback
package fr.tuxgyver.noxely.playback
import android.content.ComponentName
import android.content.Context
@@ -6,8 +6,8 @@ import androidx.core.content.ContextCompat
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,6 +28,8 @@ data class PlayerUiState(
val durationMs: Long = 0L,
val hasNext: Boolean = false,
val hasPrevious: Boolean = false,
/** D'où vient la file en cours : nom de playlist, d'album, de station. */
val queueLabel: String? = null,
) {
val isIdle: Boolean get() = mediaId == null
}
@@ -44,6 +46,7 @@ class PlayerController(
private val resolve: (String) -> MediaEntry?,
) {
private var controller: MediaController? = null
private var queueLabel: String? = null
private val _state = MutableStateFlow(PlayerUiState())
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
@@ -81,9 +84,10 @@ class PlayerController(
}
/** Lance une file de lecture à partir de l'entrée choisie. */
fun play(queue: List<MediaEntry>, startIndex: Int) {
fun play(queue: List<MediaEntry>, startIndex: Int, label: String? = null) {
val controller = controller ?: return
if (queue.isEmpty()) return
queueLabel = label
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
controller.prepare()
controller.play()
@@ -108,6 +112,7 @@ class PlayerController(
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
fun stop() {
queueLabel = null
controller?.stop()
controller?.clearMediaItems()
syncState()
@@ -132,6 +137,7 @@ class PlayerController(
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
hasNext = controller.hasNextMediaItem(),
hasPrevious = controller.hasPreviousMediaItem(),
queueLabel = queueLabel,
)
}
}
@@ -1,6 +1,6 @@
package fr.tuxgyver.cachette.radio
package fr.tuxgyver.noxely.radio
import fr.tuxgyver.cachette.sources.openConnection
import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.radio
package fr.tuxgyver.noxely.radio
/** Une station de radio et son flux direct. */
data class RadioStation(
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.radio
package fr.tuxgyver.noxely.radio
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.radio
package fr.tuxgyver.noxely.radio
import android.app.Notification
import android.app.NotificationChannel
@@ -11,13 +11,13 @@ import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import fr.tuxgyver.cachette.CachetteApp
import fr.tuxgyver.cachette.MainActivity
import fr.tuxgyver.cachette.R
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.sources.openConnection
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
@@ -246,7 +246,7 @@ class RecordingService : Service() {
val published = output.finish(title, elapsed)
if (published != null) {
val (uri, size) = published
val container = (application as CachetteApp).container
val container = (application as NoxelyApp).container
val entry = MediaEntry(
id = "rec:${uri.hashCode()}",
source = SourceKind.RECORDING,
@@ -343,8 +343,8 @@ class RecordingService : Service() {
replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(' ', '_').take(40).ifBlank { "radio" }
companion object {
private const val ACTION_START = "fr.tuxgyver.cachette.RECORD_START"
private const val ACTION_STOP = "fr.tuxgyver.cachette.RECORD_STOP"
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"
@@ -1,18 +1,18 @@
package fr.tuxgyver.cachette.radio
package fr.tuxgyver.noxely.radio
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
import java.io.File
import java.io.OutputStream
/**
* Destination d'un enregistrement radio.
*
* À partir d'Android 10, le fichier est écrit dans `Music/Cachette` via MediaStore : il devient
* À partir d'Android 10, le fichier est écrit dans `Music/Noxely` via MediaStore : il devient
* visible de tous les lecteurs de l'appareil et survit à la désinstallation. En deçà, l'écriture
* dans MediaStore réclamerait une permission de stockage que l'application n'a pas besoin de
* demander par ailleurs, donc le fichier reste dans le dossier privé.
@@ -100,7 +100,7 @@ class RecordingSink(
replace(Regex("[^\\p{L}\\p{N} ._-]"), "").trim().take(80).ifBlank { "enregistrement" }
companion object {
const val RELATIVE_PATH = "Music/Cachette"
const val RELATIVE_PATH = "Music/Noxely"
private const val MIN_USEFUL_BYTES = 16 * 1024L
/** Supprime un enregistrement, quel que soit l'endroit où il avait été écrit. */
@@ -0,0 +1,101 @@
package fr.tuxgyver.noxely.radio
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import fr.tuxgyver.noxely.NoxelyApp
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.util.concurrent.TimeUnit
/**
* Résultat d'un balayage, tel que l'interface le rapporte.
*/
data class ScanOutcome(
val stationsProbed: Int,
val titlesRead: Int,
val matched: String?,
val matchedOn: String?,
)
/**
* Passe les stations en revue à la recherche des morceaux guettés.
*
* Chaque station doit être ouverte pour livrer son titre : le balayage est donc borné aux
* stations les plus écoutées, par paquets, et non lancé en continu.
*
* ⚠️ Un morceau dure trois à quatre minutes, et WorkManager n'autorise pas de tâche périodique
* plus fréquente qu'un quart d'heure. Le balayage automatique ne peut donc pas tout attraper :
* il repère ce qui passe au moment où il regarde. Le balayage manuel existe pour cela.
*/
class WatchScanner(
private val directory: RadioDirectory,
private val store: WatchStore,
) {
suspend fun scan(context: Context, stationLimit: Int = STATION_LIMIT): ScanOutcome {
val pending = store.pending()
if (pending.isEmpty()) return ScanOutcome(0, 0, null, null)
val stations = directory.popular().getOrNull()?.take(stationLimit).orEmpty()
var titles = 0
stations.chunked(CONCURRENCY).forEach { group ->
val heard = coroutineScope {
group.map { station -> async { station to directory.nowPlaying(station.streamUrl) } }
.awaitAll()
}
for ((station, title) in heard) {
if (title == null) continue
titles++
val hit = pending.firstOrNull { title.contains(it.query, ignoreCase = true) }
?: continue
// Un seul enregistrement à la fois : inutile d'en réclamer un second.
if (!Recorder.status.value.isRecording) {
RecordingService.start(context, station, whenTitleContains = hit.query)
}
store.markFound(hit.id, station.name, title)
store.markScanned(pending.map { it.id })
return ScanOutcome(stations.size, titles, title, station.name)
}
}
store.markScanned(pending.map { it.id })
return ScanOutcome(stations.size, titles, null, null)
}
companion object {
private const val STATION_LIMIT = 40
private const val CONCURRENCY = 4
private const val WORK_NAME = "noxely-watch-scan"
/** Un quart d'heure est le minimum autorisé pour une tâche périodique. */
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<WatchWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
}
}
class WatchWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val container = (applicationContext as NoxelyApp).container
if (container.watches.pending().isEmpty()) return Result.success()
runCatching { container.watchScanner.scan(applicationContext) }
return Result.success()
}
}
@@ -0,0 +1,144 @@
package fr.tuxgyver.noxely.radio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import fr.tuxgyver.noxely.MainActivity
import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.noxely.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Veille active : balaie les stations en continu tant qu'un morceau est guetté.
*
* La tâche périodique du système ne descend pas sous le quart d'heure, quand un morceau dure
* trois à quatre minutes : elle ne peut donc qu'en manquer. Cette veille tourne en service de
* premier plan, visible et interruptible, et s'arrête d'elle-même quand il n'y a plus rien à
* guetter — il n'y a pas de raison de consommer pour rien.
*/
class WatchService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
finish()
return START_NOT_STICKY
}
if (job?.isActive == true) return START_STICKY
val container = (application as NoxelyApp).container
running.value = true
startForegroundCompat(notification(container.watches.pending().size, null))
job = scope.launch {
while (isActive) {
val pending = container.watches.pending()
if (pending.isEmpty()) break
val outcome = runCatching { container.watchScanner.scan(applicationContext) }.getOrNull()
notify(container.watches.pending().size, outcome?.matched)
if (container.watches.pending().isEmpty()) break
delay(SWEEP_INTERVAL_MS)
}
finish()
}
return START_STICKY
}
private fun finish() {
job?.cancel()
job = null
running.value = false
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
running.value = false
scope.cancel()
super.onDestroy()
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun notify(pending: Int, lastMatch: String?) {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, notification(pending, lastMatch))
}
private fun notification(pending: Int, lastMatch: String?): Notification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Veille radio", NotificationManager.IMPORTANCE_LOW)
)
}
val open = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val stop = PendingIntent.getService(
this, 1,
Intent(this, WatchService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline)
.setContentTitle("Veille radio · $pending morceau(x) guetté(s)")
.setContentText(lastMatch?.let { "Dernier capté : $it" } ?: "Balayage toutes les 3 minutes")
.setContentIntent(open)
.addAction(0, "Arrêter", stop)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
companion object {
private const val ACTION_STOP = "fr.tuxgyver.noxely.WATCH_STOP"
private const val NOTIFICATION_ID = 4501
private const val CHANNEL_ID = "noxely_watch"
/** Trois minutes : assez serré pour croiser un morceau, assez large pour la batterie. */
private const val SWEEP_INTERVAL_MS = 180_000L
private val running = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = running.asStateFlow()
fun start(context: Context) {
context.startForegroundService(Intent(context, WatchService::class.java))
}
fun stop(context: Context) {
context.startService(Intent(context, WatchService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -0,0 +1,90 @@
package fr.tuxgyver.noxely.radio
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.util.UUID
/** Un morceau guetté sur l'ensemble des stations. */
@Serializable
data class Watch(
val id: String,
val query: String,
val createdAt: Long,
/** Renseignés une fois le morceau capté. */
val foundAt: Long? = null,
val foundOn: String? = null,
val foundTitle: String? = null,
val lastScanAt: Long? = null,
val scans: Int = 0,
) {
val isFound: Boolean get() = foundAt != null
}
/** Persistance des guets, sur le même principe que les playlists. */
class WatchStore(context: Context, scope: CoroutineScope) {
private val file = File(context.filesDir, "watches.json")
private val json = Json { ignoreUnknownKeys = true }
private val mutex = Mutex()
private val _watches = MutableStateFlow<List<Watch>>(emptyList())
val watches: StateFlow<List<Watch>> = _watches.asStateFlow()
init {
scope.launch { _watches.value = read() }
}
suspend fun add(query: String) = mutate { current ->
val cleaned = query.trim()
if (cleaned.isBlank() || current.any { it.query.equals(cleaned, true) && !it.isFound }) current
else current + Watch(UUID.randomUUID().toString(), cleaned, System.currentTimeMillis())
}
suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } }
suspend fun markFound(id: String, station: String, title: String) = mutate { current ->
current.map {
if (it.id == id) it.copy(foundAt = System.currentTimeMillis(), foundOn = station, foundTitle = title)
else it
}
}
suspend fun markScanned(ids: Collection<String>) = mutate { current ->
val now = System.currentTimeMillis()
current.map { if (it.id in ids) it.copy(lastScanAt = now, scans = it.scans + 1) else it }
}
/** Les guets encore en attente, seuls concernés par un balayage. */
fun pending(): List<Watch> = _watches.value.filterNot { it.isFound }
private suspend fun mutate(transform: (List<Watch>) -> List<Watch>) = mutex.withLock {
val updated = transform(_watches.value)
if (updated != _watches.value) {
_watches.value = updated
write(updated)
}
}
private suspend fun read(): List<Watch> = withContext(Dispatchers.IO) {
if (!file.exists()) return@withContext emptyList()
runCatching { json.decodeFromString<List<Watch>>(file.readText()) }.getOrElse { emptyList() }
}
private suspend fun write(watches: List<Watch>) = withContext(Dispatchers.IO) {
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(json.encodeToString(watches))
tmp.renameTo(file)
}
}
@@ -1,8 +1,8 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
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
@@ -1,6 +1,6 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import fr.tuxgyver.cachette.model.MediaEntry
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(
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import java.net.HttpURLConnection
import java.net.URL
@@ -19,5 +19,5 @@ internal fun openConnection(
instanceFollowRedirects = true
connectTimeout = 15_000
readTimeout = readTimeoutMs
setRequestProperty("User-Agent", "Cachette/1.1 (Android)")
setRequestProperty("User-Agent", "Noxely/1.1 (Android)")
}
@@ -1,6 +1,6 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -1,9 +1,9 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import android.util.Xml
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
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
@@ -1,10 +1,10 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import android.net.Uri
import androidx.core.net.toUri
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
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
@@ -1,8 +1,8 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
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
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui
package fr.tuxgyver.noxely.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -35,22 +35,24 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen
import fr.tuxgyver.cachette.ui.browse.CatalogItemScreen
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen
import fr.tuxgyver.cachette.ui.library.LibraryScreen
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
import fr.tuxgyver.cachette.ui.player.MiniPlayer
import fr.tuxgyver.cachette.ui.radio.RadioScreen
import fr.tuxgyver.cachette.ui.playlists.AddToPlaylistSheet
import fr.tuxgyver.cachette.ui.playlists.PlaylistDetailScreen
import fr.tuxgyver.cachette.ui.playlists.PlaylistsScreen
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
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.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) {
@@ -65,9 +67,11 @@ 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 CachetteRoot(container: AppContainer) {
fun NoxelyRoot(container: AppContainer) {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
@@ -86,6 +90,10 @@ fun CachetteRoot(container: AppContainer) {
container.player.play(queue, index)
expanded = true
}
val playFrom: (List<MediaEntry>, Int, String?) -> Unit = { queue, index, label ->
container.player.play(queue, index, label)
expanded = true
}
val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } }
Box(Modifier.fillMaxSize()) {
@@ -145,12 +153,26 @@ fun CachetteRoot(container: AppContainer) {
)
}
composable(ABOUT_ROUTE) {
AboutScreen(onBack = navController::popBackStack, contentPadding = padding)
}
composable(RADIO_ROUTE) {
RadioScreen(
container = container,
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onMessage = notify,
onOpenWatches = { navController.navigate(WATCHES_ROUTE) },
contentPadding = padding,
)
}
composable(WATCHES_ROUTE) {
WatchListScreen(
container = container,
onBack = navController::popBackStack,
onMessage = notify,
contentPadding = padding,
)
}
@@ -161,7 +183,7 @@ fun CachetteRoot(container: AppContainer) {
playlistId = entry.arguments?.getString("playlist").orEmpty(),
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onPlay = play,
onPlay = playFrom,
contentPadding = padding,
)
}
@@ -174,6 +196,7 @@ fun CachetteRoot(container: AppContainer) {
onBrowseSource = { navController.navigate("browse/${it.name}") },
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
onBrowseRadio = { navController.navigate(RADIO_ROUTE) },
onOpenAbout = { navController.navigate(ABOUT_ROUTE) },
)
}
@@ -0,0 +1,355 @@
package fr.tuxgyver.noxely.ui.about
import androidx.compose.foundation.Image
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import fr.tuxgyver.noxely.R
private val TABS = listOf(
"⭐ Fonctions",
"🛡️ Garanties",
"🧱 Technique",
"🕓 Journal",
"👤 Auteur",
)
/**
* Page « À propos » : ce que fait l'application, ce qu'elle garantit, avec quoi elle est faite,
* ce qui a changé et qui en répond. L'en-tête ne défile pas ; seul l'onglet courant défile.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit, contentPadding: PaddingValues) {
var tab by rememberSaveable { mutableIntStateOf(0) }
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("À propos") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
)
Identity()
ScrollableTabRow(selectedTabIndex = tab, edgePadding = 16.dp) {
TABS.forEachIndexed { index, label ->
Tab(
selected = tab == index,
onClick = { tab = index },
text = { Text(label, style = MaterialTheme.typography.labelLarge) },
)
}
}
when (tab) {
0 -> FeaturesTab(listPadding)
1 -> GuaranteesTab(listPadding)
2 -> StackTab(listPadding)
3 -> ChangelogTab(listPadding)
else -> AuthorTab(listPadding)
}
}
}
@Composable
private fun Identity() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Image(
painter = painterResource(R.mipmap.ic_launcher_foreground),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp)),
)
Spacer(Modifier.size(6.dp))
Text(AppInfo.NAME, style = MaterialTheme.typography.headlineSmall)
Text(
text = AppInfo.TAGLINE,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Pill("v${AppInfo.VERSION}", MaterialTheme.colorScheme.primary)
// Rouge tant qu'aucune licence n'est déclarée : une garantie qui manque doit se voir.
Pill(
text = AppInfo.LICENSE,
color = if (AppInfo.LICENSE_DEFINED) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.error,
)
Pill("Hors ligne", Color(0xFF2E7D32))
}
Spacer(Modifier.size(12.dp))
}
}
@Composable
private fun Pill(text: String, color: Color) {
Surface(color = color.copy(alpha = 0.14f), shape = CircleShape) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = color,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
}
@Composable
private fun FeaturesTab(padding: PaddingValues) {
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(AppInfo.FEATURES) { (title, description) ->
Column {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item { Spacer(Modifier.size(16.dp)) }
}
}
@Composable
private fun GuaranteesTab(padding: PaddingValues) {
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
item {
Text(
text = AppInfo.GUARANTEES_INTRO,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.size(4.dp))
}
items(AppInfo.GUARANTEES) { guarantee ->
Row {
Text("•", color = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(10.dp))
Text(
text = guarantee,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item { Spacer(Modifier.size(16.dp)) }
}
}
@Composable
private fun StackTab(padding: PaddingValues) {
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(AppInfo.STACK) { (layer, tools) ->
Row(verticalAlignment = Alignment.Top) {
Text(
text = layer,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.width(116.dp),
)
Text(
text = tools,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
}
item { Spacer(Modifier.size(16.dp)) }
}
}
@Composable
private fun ChangelogTab(padding: PaddingValues) {
val context = LocalContext.current
val lines = remember { readChangelog(context) }
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
if (lines.isEmpty()) {
item {
Text(
text = "Journal indisponible.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(lines) { line ->
when (line) {
is ChangelogLine.Version -> {
Spacer(Modifier.size(8.dp))
Text(
text = line.text,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
is ChangelogLine.Section -> Text(
text = line.text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.secondary,
)
is ChangelogLine.Bullet -> Row {
Text("•", color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
Text(
text = emphasised(line.text),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
item { Spacer(Modifier.size(16.dp)) }
}
}
/** Le fichier n'emploie que du gras : inutile d'embarquer un analyseur Markdown complet. */
@Composable
private fun emphasised(text: String) = buildAnnotatedString {
var rest = text
while (true) {
val open = rest.indexOf("**")
if (open < 0) break
val close = rest.indexOf("**", open + 2)
if (close < 0) break
append(rest.substring(0, open))
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(rest.substring(open + 2, close))
}
rest = rest.substring(close + 2)
}
append(rest)
}
@Composable
private fun AuthorTab(padding: PaddingValues) {
LazyColumn(
contentPadding = padding,
modifier = Modifier.padding(horizontal = 16.dp),
) {
item {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(52.dp)
.clip(CircleShape),
) {
Surface(color = MaterialTheme.colorScheme.primary, shape = CircleShape) {
Box(Modifier.size(52.dp), contentAlignment = Alignment.Center) {
Text(
text = AppInfo.AUTHOR.take(1).uppercase(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
Spacer(Modifier.width(16.dp))
Column {
Text(AppInfo.AUTHOR, style = MaterialTheme.typography.titleMedium)
Text(
text = AppInfo.AUTHOR_ROLE,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.size(18.dp))
Text("Dépôt", style = MaterialTheme.typography.labelMedium)
Text(
text = AppInfo.REPOSITORY,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.size(18.dp))
Text(
text = AppInfo.AUTHORSHIP,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.size(16.dp))
}
}
}
@@ -0,0 +1,83 @@
package fr.tuxgyver.noxely.ui.about
import fr.tuxgyver.noxely.BuildConfig
/**
* Contenu de la page « À propos », séparé de son rendu.
*
* Tout ce qui est écrit ici se vérifie dans le dépôt : une fonction décrite existe, une garantie
* annoncée est tenue par du code. La version n'est pas recopiée mais lue du manifeste.
*/
object AppInfo {
const val NAME = "Noxely"
const val TAGLINE = "Mettez la musique de côté pendant que vous avez du réseau."
val VERSION: String get() = BuildConfig.VERSION_NAME
const val REPOSITORY = "https://git.tuxtech.fr/tuxgyver/Noxely"
const val AUTHOR = "Johnny"
const val AUTHOR_ROLE = "Conception, direction et supervision"
const val AUTHORSHIP =
"Application développée avec l'assistance de l'IA, sous la direction et la supervision de Johnny."
/** Aucune licence n'est déclarée dans le dépôt : la pastille le signale plutôt que d'en inventer une. */
const val LICENSE = "Licence à définir"
const val LICENSE_DEFINED = false
/** 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.",
"Organiser en playlists" to
"Des listes nommées, alimentées d'un appui long, lues à la suite.",
"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.",
)
/** 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."
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.",
"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.",
"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.",
)
/** Couche → outils. Six lignes suffisent. */
val STACK = listOf(
"Interface" to "Jetpack Compose, Material 3, thèmes clair et sombre",
"Lecture" to "Media3 / ExoPlayer, MediaSessionService, notification système",
"Hors connexion" to "Cache Media3 non évinçant, WorkManager pour la reprise",
"Bibliothèque" to "MediaStore pour l'appareil, fichiers JSON pour les imports",
"Réseau" to "HttpURLConnection, kotlinx.serialization, flux ICY pour la radio",
"Publication" to "APK signé, releases Gitea, mise à jour intégrée",
)
}
@@ -0,0 +1,53 @@
package fr.tuxgyver.noxely.ui.about
import android.content.Context
/** Une ligne du journal, telle qu'elle sera rendue. */
sealed interface ChangelogLine {
data class Version(val text: String) : ChangelogLine
data class Section(val text: String) : ChangelogLine
data class Bullet(val text: String) : ChangelogLine
}
/**
* Lit le CHANGELOG livré dans les ressources.
*
* Le fichier est celui du dépôt, recopié dans les assets à la compilation : le journal affiché
* ne peut donc pas diverger de celui que la publication utilise pour ses notes de version.
* Seules les formes réellement employées par le fichier sont interprétées — inutile d'embarquer
* un analyseur Markdown complet pour trois motifs. Le préambule qui précède la première version
* est ignoré : il décrit la convention du fichier, pas l'histoire du logiciel.
*/
fun readChangelog(context: Context, limitVersions: Int = 6): List<ChangelogLine> {
val raw = runCatching {
context.assets.open("CHANGELOG.md").bufferedReader().readText()
}.getOrNull() ?: return emptyList()
val lines = mutableListOf<ChangelogLine>()
var versions = 0
for (line in raw.lineSequence()) {
val trimmed = line.trim()
when {
trimmed.startsWith("## ") -> {
versions++
if (versions > limitVersions) break
lines += ChangelogLine.Version(trimmed.removePrefix("## ").replace("[", "").replace("]", ""))
}
trimmed.startsWith("### ") ->
lines += ChangelogLine.Section(trimmed.removePrefix("### "))
trimmed.startsWith("- ") && versions > 0 ->
lines += ChangelogLine.Bullet(trimmed.removePrefix("- "))
// Une puce peut courir sur plusieurs lignes : on la recolle à la précédente.
trimmed.isNotEmpty() && versions > 0 && lines.lastOrNull() is ChangelogLine.Bullet -> {
val previous = lines.removeAt(lines.lastIndex) as ChangelogLine.Bullet
lines += ChangelogLine.Bullet("${previous.text} $trimmed")
}
}
}
return lines
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.browse
package fr.tuxgyver.noxely.ui.browse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -47,12 +47,12 @@ 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.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.sources.CatalogItem
import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatBytes
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
/**
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.browse
package fr.tuxgyver.noxely.ui.browse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -40,11 +40,11 @@ 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.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.ui.components.formatBytes
import fr.tuxgyver.cachette.ui.components.formatDuration
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
/**
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.browse
package fr.tuxgyver.noxely.ui.browse
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.browse
package fr.tuxgyver.noxely.ui.browse
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -35,10 +35,10 @@ 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.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.ui.components.MediaRow
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,
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.components
package fr.tuxgyver.noxely.ui.components
import android.graphics.Bitmap
import android.os.Build
@@ -28,7 +28,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import coil.compose.AsyncImage
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.noxely.model.MediaKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.components
package fr.tuxgyver.noxely.ui.components
import java.util.Locale
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.components
package fr.tuxgyver.noxely.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -32,8 +32,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.offline.OfflineState
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.offline.OfflineState
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.library
package fr.tuxgyver.noxely.ui.library
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -40,12 +40,12 @@ 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.cachette.AppContainer
import fr.tuxgyver.cachette.library.LocalMediaScanner
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.offline.OfflineState
import fr.tuxgyver.cachette.ui.components.MediaRow
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.library.LocalMediaScanner
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.offline.OfflineState
import fr.tuxgyver.noxely.ui.components.MediaRow
private enum class LibraryFilter(val label: String) {
ALL("Tout"),
@@ -201,7 +201,7 @@ private fun PermissionCard(onGrant: () -> Unit) {
Text("Accéder à vos médias", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.size(6.dp))
Text(
"Cachette lit la musique et les vidéos déjà présentes sur l'appareil. Rien n'est envoyé ailleurs.",
"Noxely lit la musique et les vidéos déjà présentes sur l'appareil. Rien n'est envoyé ailleurs.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.size(14.dp))
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.offline
package fr.tuxgyver.noxely.ui.offline
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,7 +15,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -37,12 +39,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.radio.RecordingSink
import fr.tuxgyver.cachette.ui.components.MediaRow
import fr.tuxgyver.cachette.ui.components.formatBytes
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.radio.RecordingSink
import fr.tuxgyver.noxely.ui.components.MediaRow
import fr.tuxgyver.noxely.ui.components.formatBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -59,6 +61,8 @@ fun DownloadsScreen(
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
var confirmClear by remember { mutableStateOf(false) }
var pendingRecordingDelete by remember { mutableStateOf<MediaEntry?>(null) }
var refreshing by remember { mutableStateOf(false) }
var refreshNote by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var cacheSize by remember { mutableLongStateOf(0L) }
@@ -97,7 +101,32 @@ fun DownloadsScreen(
cacheSize = cacheSize,
completed = completed,
running = running,
refreshing = refreshing,
note = refreshNote,
onClear = { confirmClear = true },
onRefresh = {
refreshing = true
refreshNote = null
scope.launch {
container.library.refreshLocal()
// Les pochettes manquantes sont cherchées une par une : un enregistrement
// radio ou un fichier importé n'en a aucune tant qu'on ne l'a pas cherchée.
val missing = cached.filter { it.artworkUri.isNullOrBlank() }
var found = 0
missing.take(MAX_ARTWORK_LOOKUPS).forEach { entry ->
container.artwork.find(entry)?.let { url ->
container.imported.setArtwork(entry.id, url)
found++
}
}
refreshing = false
refreshNote = when {
missing.isEmpty() -> "Tout est à jour"
found == 0 -> "Aucune pochette trouvée pour ${missing.size} titre(s)"
else -> "$found pochette(s) ajoutée(s)"
}
}
},
)
Spacer(Modifier.size(14.dp))
}
@@ -188,14 +217,18 @@ private fun StorageCard(
cacheSize: Long,
completed: Int,
running: Int,
refreshing: Boolean,
note: String?,
onClear: () -> Unit,
onRefresh: () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(20.dp),
) {
@@ -212,13 +245,31 @@ private fun StorageCard(
style = MaterialTheme.typography.bodyMedium,
)
}
if (refreshing) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
} else {
IconButton(onClick = onRefresh) {
Icon(Icons.Rounded.Refresh, contentDescription = "Actualiser et chercher les pochettes")
}
}
if (completed > 0 || running > 0) {
TextButton(onClick = onClear) { Text("Tout libérer") }
}
}
note?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 14.dp),
)
}
}
}
}
/** Chaque recherche est une requête réseau : on borne le lot pour ne pas marteler le service. */
private const val MAX_ARTWORK_LOOKUPS = 25
@Composable
private fun EmptyOffline() {
Box(
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.player
package fr.tuxgyver.noxely.ui.player
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
@@ -26,8 +26,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.playback.PlayerUiState
import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.noxely.playback.PlayerUiState
import fr.tuxgyver.noxely.ui.components.Artwork
@Composable
fun MiniPlayer(
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.player
package fr.tuxgyver.noxely.ui.player
import androidx.annotation.OptIn
import androidx.compose.foundation.background
@@ -36,6 +36,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -54,12 +55,16 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.offline.OfflineState
import fr.tuxgyver.cachette.playback.PlayerUiState
import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatDuration
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.offline.OfflineState
import fr.tuxgyver.noxely.playback.PlayerUiState
import fr.tuxgyver.noxely.ui.components.Artwork
import fr.tuxgyver.noxely.ui.components.formatDuration
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import fr.tuxgyver.noxely.ui.components.formatBytes
@Composable
fun NowPlayingScreen(
@@ -170,7 +175,12 @@ fun NowPlayingScreen(
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.size(20.dp))
entry?.let { current ->
Spacer(Modifier.size(10.dp))
TrackDetails(entry = current, queueLabel = state.queueLabel)
}
Spacer(Modifier.size(16.dp))
Slider(
value = scrubbing ?: if (state.durationMs > 0) {
@@ -264,6 +274,36 @@ private fun SkipButton(
}
}
/**
* Ce qu'on sait du morceau en cours : d'où il est lu, ce qu'il pèse, quand il est arrivé.
* Les champs absents ne laissent pas de trou — ils ne s'affichent simplement pas.
*/
@Composable
private fun TrackDetails(entry: MediaEntry, queueLabel: String?) {
val details = buildList {
queueLabel?.takeIf { it.isNotBlank() }?.let { add("📋 $it") }
add(entry.source.label)
entry.sizeBytes?.takeIf { it > 0 }?.let { add(formatBytes(it)) }
entry.addedAt.takeIf { it > 0 }?.let {
add(SimpleDateFormat("d MMM yyyy", Locale.FRANCE).format(Date(it)))
}
}
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
shape = MaterialTheme.shapes.small,
) {
Text(
text = details.joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
}
@Composable
private fun OfflineToggle(
entry: MediaEntry?,
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.playlists
package fr.tuxgyver.noxely.ui.playlists
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -35,8 +35,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.launch
/** Choix de la playlist d'accueil, avec création à la volée si aucune ne convient. */
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.playlists
package fr.tuxgyver.noxely.ui.playlists
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -33,9 +33,9 @@ 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.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.ui.components.MediaRow
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.ui.components.MediaRow
import kotlinx.coroutines.launch
/** Le contenu d'une playlist, dans l'ordre d'ajout. */
@@ -46,7 +46,7 @@ fun PlaylistDetailScreen(
playlistId: String,
currentMediaId: String?,
onBack: () -> Unit,
onPlay: (List<MediaEntry>, Int) -> Unit,
onPlay: (List<MediaEntry>, Int, String?) -> Unit,
contentPadding: PaddingValues,
) {
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
@@ -81,7 +81,7 @@ fun PlaylistDetailScreen(
if (tracks.isNotEmpty()) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
Button(onClick = { onPlay(tracks, 0) }) {
Button(onClick = { onPlay(tracks, 0, playlist?.name) }) {
Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text("Tout lire")
@@ -115,7 +115,7 @@ fun PlaylistDetailScreen(
entry = entry,
offline = offlineStates[entry.id],
isCurrent = entry.id == currentMediaId,
onClick = { onPlay(tracks, tracks.indexOf(entry)) },
onClick = { onPlay(tracks, tracks.indexOf(entry), playlist?.name) },
onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) },
modifier = Modifier.weight(1f),
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.playlists
package fr.tuxgyver.noxely.ui.playlists
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -39,7 +39,7 @@ 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.cachette.AppContainer
import fr.tuxgyver.noxely.AppContainer
import kotlinx.coroutines.launch
@Composable
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.radio
package fr.tuxgyver.noxely.ui.radio
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.rounded.FiberManualRecord
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.PlayCircleOutline
import androidx.compose.material.icons.rounded.Radar
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Button
@@ -57,17 +58,17 @@ 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.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.radio.RADIO_GENRES
import fr.tuxgyver.cachette.radio.RadioStation
import fr.tuxgyver.cachette.radio.Recorder
import fr.tuxgyver.cachette.radio.RecordingService
import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatBytes
import fr.tuxgyver.cachette.ui.components.formatDuration
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
@@ -86,6 +87,7 @@ fun RadioScreen(
currentMediaId: String?,
onBack: () -> Unit,
onMessage: (String) -> Unit,
onOpenWatches: () -> Unit,
contentPadding: PaddingValues,
) {
val context = LocalContext.current
@@ -135,6 +137,11 @@ fun RadioScreen(
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Radio") },
actions = {
IconButton(onClick = onOpenWatches) {
Icon(Icons.Rounded.Radar, contentDescription = "Morceaux guettés")
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
@@ -258,7 +265,7 @@ fun RadioScreen(
onDismiss = { scheduleFor = null },
onConfirm = { wanted ->
RecordingService.start(context, station, whenTitleContains = wanted)
onMessage("Cachette guette « $wanted » sur ${station.name}")
onMessage("Noxely guette « $wanted » sur ${station.name}")
scheduleFor = null
},
)
@@ -293,7 +300,7 @@ private fun ScheduleDialog(
text = {
Column {
Text(
text = "Cachette écoutera ${station.name} et déclenchera l'enregistrement dès " +
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,
)
@@ -0,0 +1,271 @@
package fr.tuxgyver.noxely.ui.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.HourglassEmpty
import androidx.compose.material.icons.rounded.Radar
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.radio.Watch
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Les morceaux guettés sur l'ensemble des stations : ceux qu'on attend, et ceux qui sont passés.
*
* Le balayage automatique tourne au quart d'heure — le minimum qu'autorise le système. Un morceau
* durant trois à quatre minutes, il en manque forcément : le bouton « Balayer » sert à regarder
* tout de suite, quand on sait qu'un titre tourne.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WatchListScreen(
container: AppContainer,
onBack: () -> Unit,
onMessage: (String) -> Unit,
contentPadding: PaddingValues,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val watches by container.watches.watches.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var scanning by remember { mutableStateOf(false) }
val watching by fr.tuxgyver.noxely.radio.WatchService.isRunning.collectAsStateWithLifecycle()
val pending = watches.filterNot { it.isFound }
val found = watches.filter { it.isFound }
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Guets") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
actions = {
if (scanning) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(22.dp).padding(end = 8.dp),
)
} else if (pending.isNotEmpty()) {
IconButton(onClick = {
scanning = true
scope.launch {
val outcome = runCatching { container.watchScanner.scan(context) }.getOrNull()
scanning = false
onMessage(
when {
outcome == null -> "Balayage impossible"
outcome.matched != null ->
"Trouvé sur ${outcome.matchedOn} : ${outcome.matched}"
else ->
"${outcome.stationsProbed} stations écoutées, ${outcome.titlesRead} titres lus, rien de guetté"
}
)
}
}) {
Icon(Icons.Rounded.Radar, contentDescription = "Balayer maintenant")
}
}
},
)
if (pending.isNotEmpty()) {
Surface(
color = if (watching) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(14.dp)) {
Column(Modifier.weight(1f)) {
Text(
text = if (watching) "Veille active" else "Veille en arrière-plan",
style = MaterialTheme.typography.titleSmall,
)
Text(
text = if (watching) {
"Les stations sont balayées toutes les 3 minutes, écran éteint compris."
} else {
"Sans elle, le balayage n'a lieu qu'au quart d'heure : un morceau de " +
"trois minutes passe souvent entre deux."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(10.dp))
Button(onClick = {
if (watching) fr.tuxgyver.noxely.radio.WatchService.stop(context)
else fr.tuxgyver.noxely.radio.WatchService.start(context)
}) {
Text(if (watching) "Arrêter" else "Activer")
}
}
}
Spacer(Modifier.size(8.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Titre ou artiste à guetter") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
enabled = query.isNotBlank(),
onClick = {
// La valeur est capturée avant d'être effacée : la coroutine lirait sinon
// un champ déjà vidé, et n'enregistrerait rien.
val wanted = query.trim()
scope.launch { container.watches.add(wanted) }
onMessage("« $wanted » est guetté sur toutes les stations")
query = ""
},
) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
LazyColumn(
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
if (watches.isEmpty()) {
item {
Box(
Modifier.fillMaxWidth().padding(vertical = 48.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Nommez un morceau : Noxely écoutera les stations les plus suivies " +
"et l'enregistrera dès qu'il passera.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
if (pending.isNotEmpty()) {
item { SectionTitle("En attente (${pending.size})") }
items(pending, key = { it.id }) { watch ->
WatchRow(watch) { scope.launch { container.watches.remove(watch.id) } }
}
}
if (found.isNotEmpty()) {
item { SectionTitle("Trouvés (${found.size})") }
items(found, key = { it.id }) { watch ->
WatchRow(watch) { scope.launch { container.watches.remove(watch.id) } }
}
}
item { Spacer(Modifier.size(24.dp)) }
}
}
}
@Composable
private fun SectionTitle(text: String) {
Spacer(Modifier.size(10.dp))
Text(text, style = MaterialTheme.typography.titleSmall)
}
@Composable
private fun WatchRow(watch: Watch, onRemove: () -> Unit) {
Surface(
color = if (watch.isFound) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
tonalElevation = 1.dp,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) {
Icon(
imageVector = if (watch.isFound) Icons.Rounded.CheckCircle else Icons.Rounded.HourglassEmpty,
contentDescription = null,
tint = if (watch.isFound) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = watch.query,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (watch.isFound) {
"Capté sur ${watch.foundOn} · ${watch.foundTitle?.take(48).orEmpty()}"
} else {
val last = watch.lastScanAt
?.let { SimpleDateFormat("HH:mm", Locale.FRANCE).format(Date(it)) }
"${watch.scans} balayage(s)" + (last?.let { ", dernier à $it" } ?: ", pas encore balayé")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onRemove) {
Icon(Icons.Rounded.DeleteOutline, contentDescription = "Retirer ce guet")
}
}
}
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.sources
package fr.tuxgyver.noxely.ui.sources
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -17,6 +17,7 @@ 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
@@ -49,9 +50,9 @@ 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.cachette.AppContainer
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.sources.CatalogId
import kotlinx.coroutines.launch
@Composable
@@ -62,6 +63,7 @@ fun SourcesScreen(
onBrowseSource: (SourceKind) -> Unit,
onBrowseCatalog: (CatalogId) -> Unit,
onBrowseRadio: () -> Unit,
onOpenAbout: () -> Unit,
) {
val entries by container.library.entries.collectAsStateWithLifecycle()
var sheet by remember { mutableStateOf<ImportKind?>(null) }
@@ -176,6 +178,18 @@ fun SourcesScreen(
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(
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.sources
package fr.tuxgyver.noxely.ui.sources
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -24,10 +24,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.BuildConfig
import fr.tuxgyver.cachette.update.UpdateInfo
import fr.tuxgyver.cachette.update.UpdateNotifier
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.BuildConfig
import fr.tuxgyver.noxely.update.UpdateInfo
import fr.tuxgyver.noxely.update.UpdateNotifier
import kotlinx.coroutines.launch
import java.io.File
@@ -82,7 +82,7 @@ fun UpdateCard(container: AppContainer) {
Text(
text = when (current) {
UpdateUiState.Idle ->
"Cachette regarde chaque jour si une nouvelle version a été publiée, et la télécharge en Wi-Fi."
"Noxely regarde chaque jour si une nouvelle version a été publiée, et la télécharge en Wi-Fi."
UpdateUiState.Checking -> "Vérification en cours…"
UpdateUiState.UpToDate -> "Vous avez déjà la dernière version."
is UpdateUiState.Available -> "Version ${current.info.version} disponible."
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.theme
package fr.tuxgyver.noxely.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -57,7 +57,7 @@ private val DarkColors = darkColorScheme(
outlineVariant = Color(0xFF3A3048),
)
private val CachetteShapes = Shapes(
private val NoxelyShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(14.dp),
medium = RoundedCornerShape(20.dp),
@@ -65,7 +65,7 @@ private val CachetteShapes = Shapes(
extraLarge = RoundedCornerShape(36.dp),
)
private val CachetteTypography = Typography().run {
private val NoxelyTypography = Typography().run {
copy(
displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp),
headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.4).sp),
@@ -77,7 +77,7 @@ private val CachetteTypography = Typography().run {
}
@Composable
fun CachetteTheme(
fun NoxelyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
@@ -87,8 +87,8 @@ fun CachetteTheme(
val colors = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colors,
shapes = CachetteShapes,
typography = CachetteTypography,
shapes = NoxelyShapes,
typography = NoxelyTypography,
content = content,
)
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.update
package fr.tuxgyver.noxely.update
import android.content.Context
import androidx.work.Constraints
@@ -8,7 +8,7 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import fr.tuxgyver.cachette.CachetteApp
import fr.tuxgyver.noxely.NoxelyApp
import java.util.concurrent.TimeUnit
/**
@@ -21,7 +21,7 @@ class UpdateCheckWorker(
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val repository = (applicationContext as CachetteApp).container.updates
val repository = (applicationContext as NoxelyApp).container.updates
val update = runCatching { repository.checkForUpdate() }.getOrNull()
?: return Result.success()
val apk = runCatching { repository.downloadApk(update) }.getOrNull()
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.update
package fr.tuxgyver.noxely.update
import android.Manifest
import android.app.NotificationChannel
@@ -12,7 +12,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import fr.tuxgyver.cachette.R
import fr.tuxgyver.noxely.R
import java.io.File
/** Prévient que la mise à jour est téléchargée et ouvre l'installateur système d'un appui. */
@@ -38,7 +38,7 @@ object UpdateNotifier {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline)
.setContentTitle("Cachette $version est prête")
.setContentTitle("Noxely $version est prête")
.setContentText("Appuyez pour installer la mise à jour.")
.setContentIntent(pending)
.setAutoCancel(true)
@@ -73,7 +73,7 @@ object UpdateNotifier {
CHANNEL_ID,
"Mises à jour",
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = "Disponibilité d'une nouvelle version de Cachette" }
).apply { description = "Disponibilité d'une nouvelle version de Noxely" }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
@@ -1,7 +1,7 @@
package fr.tuxgyver.cachette.update
package fr.tuxgyver.noxely.update
import android.content.Context
import fr.tuxgyver.cachette.BuildConfig
import fr.tuxgyver.noxely.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -103,7 +103,7 @@ class UpdateRepository(
instanceFollowRedirects = true
connectTimeout = 15_000
readTimeout = 60_000
setRequestProperty("User-Agent", "Cachette/$currentVersion (Android)")
setRequestProperty("User-Agent", "Noxely/$currentVersion (Android)")
setRequestProperty("Authorization", "token ${BuildConfig.GITEA_UPDATE_TOKEN}")
}
@@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Note de musique posée sur une flèche descendante : « mis de côté, hors ligne ». -->
<path
android:fillColor="#FFB79CFF"
android:pathData="M64,28 L64,58.5 a10,10 0 1,1 -6,-9.2 L58,37.5 L44,40.6 L44,64 a10,10 0 1,1 -6,-9.2 L38,35.8 L64,28 Z" />
<path
android:fillColor="#FF7CE7D6"
android:pathData="M54,70 L54,80 L47,80 L54,88 L61,80 L54,80 Z" />
</vector>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<resources>
<style name="Theme.Cachette" parent="android:Theme.Material.NoActionBar">
<style name="Theme.Noxely" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
+1 -1
View File
@@ -1,4 +1,4 @@
<resources>
<color name="splash_background">#FFF7F2FA</color>
<color name="ic_launcher_background">#FF1B1033</color>
<color name="ic_launcher_background">#FF0E9CC7</color>
</resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<resources>
<string name="app_name">Cachette</string>
<string name="app_name">Noxely</string>
<string name="download_channel_name">Téléchargements</string>
</resources>
+1 -1
View File
@@ -1,5 +1,5 @@
<resources>
<style name="Theme.Cachette" parent="android:Theme.Material.Light.NoActionBar">
<style name="Theme.Noxely" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
+1 -1
View File
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "Cachette"
rootProject.name = "Noxely"
include(":app")