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

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

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

La page À propos dit enfin ce que l'application fait, ce qu'elle garantit, avec
quoi elle est construite et qui en répond. Sa version vient du manifeste et son
journal du CHANGELOG du dépôt : ni l'un ni l'autre ne peut dériver. La licence
n'étant pas déclarée, la pastille le signale en rouge au lieu d'en inventer une.
2026-09-13 13:30:39 +02:00
tuxgyver b9da43366b Titre en cours, guet d'un morceau, catalogue underground (1.5.0)
On ne savait pas ce qui passait sur une station sans la lancer. Le morceau
diffusé est maintenant lu dans le flux lui-même — aucun annuaire ne l'expose —
pour les douze premières stations affichées, quatre à la fois. Au-delà, ouvrir
une connexion par station ne se justifierait plus.

De là découle le guet : on nomme un morceau ou un artiste, et l'enregistrement
se déclenche dès que la station l'annonce, puis s'arrête à la fin du morceau.
Le flux est lu sans être écrit pendant l'attente, et une station qui n'annonce
rien refuse la programmation plutôt que d'attendre indéfiniment.

Les enregistrements rejoignent Music/Cachette et MediaStore : ils deviennent
visibles des autres lecteurs et survivent à la désinstallation. Le nom du
fichier doit porter le titre, car en publiant le fichier MediaStore le relit et
réécrit ce champ depuis le nom ; et le scan local ignore ce dossier, faute de
quoi chaque enregistrement apparaîtrait deux fois. Chacun se supprime désormais
seul, fichier, entrée MediaStore et référence comprises.

Enfin, un catalogue Underground restreint aux collections netlabels et
freemusicarchive, où tout est publié sous licence libre par les artistes :
le fonds indépendant que la recherche généraliste noyait.
2026-09-13 12:56:16 +02:00
70 changed files with 1276 additions and 302 deletions

No files matched your search

+3 -1
View File
@@ -10,5 +10,7 @@ captures/
keystore.properties keystore.properties
*.jks *.jks
.gitea-token .gitea-token
Cachette-*.apk # Motif large : un motif au nom du projet a laissé passer 95 Mo lors du renommage.
*.apk
update-token.properties update-token.properties
app/src/main/assets/CHANGELOG.md
+56 -2
View File
@@ -1,10 +1,64 @@
# Changelog # 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/) Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
et le versionnage [SemVer](https://semver.org/lang/fr/). et le versionnage [SemVer](https://semver.org/lang/fr/).
## [1.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é
- **Titre en cours par station.** Aucun annuaire ne l'expose : le morceau diffusé est lu dans le
flux lui-même. Les douze premières stations affichées sont sondées quatre à la fois, et le
résultat conservé — au-delà, le nombre de connexions ne se justifierait plus. Les stations qui
n'annoncent rien gardent leur description.
- **Guet d'un titre.** On indique un morceau ou un artiste, et l'enregistrement se déclenche dès
que la station l'annonce, puis s'arrête à la fin du morceau. Le flux est lu sans être écrit
pendant l'attente. Une station sans métadonnées refuse la programmation plutôt que d'attendre
indéfiniment.
- **Catalogue Underground.** Restreint aux collections `netlabels` et `freemusicarchive`, où tout
est publié sous licence libre par les artistes : environ 94 000 morceaux indépendants, que la
recherche généraliste noyait.
- **Suppression individuelle d'un enregistrement** depuis l'écran Hors ligne : fichier, entrée
MediaStore et référence en bibliothèque sont retirés ensemble.
### Modifié
- 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.
- Le nom de fichier porte le titre du morceau. En publiant le fichier, MediaStore le relit et
réécrit le champ `TITLE` depuis ce nom : l'y inscrire directement ne survivait pas.
## [1.4.0] — 2026-09-13 ## [1.4.0] — 2026-09-13
### Ajouté ### Ajouté
@@ -82,7 +136,7 @@ et le versionnage [SemVer](https://semver.org/lang/fr/).
### Ajouté ### 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 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. 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 - 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. # app-release-unsigned.apk) : on liste le répertoire au lieu de deviner.
RELEASE_DIR := app/build/outputs/apk/release RELEASE_DIR := app/build/outputs/apk/release
SIGNED_APK := $(RELEASE_DIR)/app-release.apk SIGNED_APK := $(RELEASE_DIR)/app-release.apk
OUTPUT_APK := Cachette-$(VERSION).apk OUTPUT_APK := Noxely-$(VERSION).apk
# --- Publication ----------------------------------------------------------- # --- Publication -----------------------------------------------------------
# Déléguée au script de BorisSkill, qui sait déjà tout faire : résolution du # Déléguée au script de BorisSkill, qui sait déjà tout faire : résolution du
+34 -6
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. > 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) [![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) [![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) [![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.4.0-6B4EFF)](VERSION) [![Version](https://img.shields.io/badge/Version-1.6.0-6B4EFF)](VERSION)
[![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) [![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 forfaits épuisés. On choisit ce qu'on emporte, l'application le télécharge, et tout reste lisible
sans la moindre connexion. sans la moindre connexion.
@@ -44,6 +44,9 @@ 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 | | 🔁 **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 | | 📻 **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 | | 🏷️ **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 |
| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation | | 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation |
--- ---
@@ -59,6 +62,7 @@ sans la moindre connexion.
| 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue | | 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue |
| 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres | | 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres |
| 📻 Radio | ✅ | Annuaire Radio Browser, direct et enregistrement (flux HLS exclus) | | 📻 Radio | ✅ | Annuaire Radio Browser, direct et enregistrement (flux HLS exclus) |
| 🎧 Underground | ✅ | Netlabels et Free Music Archive : ~94 000 morceaux d'artistes indépendants |
### 🔎 Catalogues évalués puis écartés ### 🔎 Catalogues évalués puis écartés
@@ -68,7 +72,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. | | **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. | | **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 | | Service | Pourquoi |
|---|---| |---|---|
@@ -191,9 +195,33 @@ 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/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 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/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.
---
## 🆕 Mises à jour ## 🆕 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) Release publiée ──► vérification quotidienne (WorkManager, Wi-Fi)
@@ -226,7 +254,7 @@ giteaUpdateToken=…
``` ```
app/src/main/java/fr/tuxgyver/cachette/ 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 ├── MainActivity.kt
├── model/ MediaEntry, MediaKind, SourceKind ├── model/ MediaEntry, MediaKind, SourceKind
├── library/ MediaStore, persistance des imports, bibliothèque unifiée ├── library/ MediaStore, persistance des imports, bibliothèque unifiée
+1 -1
View File
@@ -1 +1 @@
1.4.0 1.6.0
+10 -3
View File
@@ -8,11 +8,11 @@ plugins {
} }
android { android {
namespace = "fr.tuxgyver.cachette" namespace = "fr.tuxgyver.noxely"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "fr.tuxgyver.cachette" applicationId = "fr.tuxgyver.noxely"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
@@ -33,7 +33,7 @@ android {
"" ""
} }
buildConfigField("String", "GITEA_UPDATE_TOKEN", "\"$updateToken\"") 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 { 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 { dependencies {
implementation(platform("androidx.compose:compose-bom:2025.10.00")) implementation(platform("androidx.compose:compose-bom:2025.10.00"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
+4 -4
View File
@@ -18,7 +18,7 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<application <application
android:name=".CachetteApp" android:name=".NoxelyApp"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -26,13 +26,13 @@
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@style/Theme.Cachette"> android:theme="@style/Theme.Noxely">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:theme="@style/Theme.Cachette" android:theme="@style/Theme.Noxely"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -50,7 +50,7 @@
</service> </service>
<service <service
android:name=".offline.CachetteDownloadService" android:name=".offline.NoxelyDownloadService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync"> android:foregroundServiceType="dataSync">
<intent-filter> <intent-filter>
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette package fr.tuxgyver.noxely
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -9,8 +9,8 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import fr.tuxgyver.cachette.ui.CachetteRoot import fr.tuxgyver.noxely.ui.NoxelyRoot
import fr.tuxgyver.cachette.ui.theme.CachetteTheme import fr.tuxgyver.noxely.ui.theme.NoxelyTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -21,20 +21,20 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val container = (application as CachetteApp).container val container = (application as NoxelyApp).container
container.player.connect() container.player.connect()
askForNotificationsIfNeeded() askForNotificationsIfNeeded()
setContent { setContent {
CachetteTheme { NoxelyTheme {
CachetteRoot(container) NoxelyRoot(container)
} }
} }
} }
override fun onDestroy() { override fun onDestroy() {
if (isFinishing) { if (isFinishing) {
(application as CachetteApp).container.player.release() (application as NoxelyApp).container.player.release()
} }
super.onDestroy() super.onDestroy()
} }
@@ -1,25 +1,25 @@
package fr.tuxgyver.cachette package fr.tuxgyver.noxely
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import fr.tuxgyver.cachette.library.ImportedStore import fr.tuxgyver.noxely.library.ImportedStore
import fr.tuxgyver.cachette.library.LibraryRepository import fr.tuxgyver.noxely.library.LibraryRepository
import fr.tuxgyver.cachette.library.LocalMediaScanner import fr.tuxgyver.noxely.library.LocalMediaScanner
import fr.tuxgyver.cachette.library.PlaylistStore import fr.tuxgyver.noxely.library.PlaylistStore
import fr.tuxgyver.cachette.offline.DownloadTracker import fr.tuxgyver.noxely.offline.DownloadTracker
import fr.tuxgyver.cachette.offline.OfflineModule import fr.tuxgyver.noxely.offline.OfflineModule
import fr.tuxgyver.cachette.playback.PlayerController import fr.tuxgyver.noxely.playback.PlayerController
import fr.tuxgyver.cachette.radio.RadioDirectory import fr.tuxgyver.noxely.radio.RadioDirectory
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog import fr.tuxgyver.noxely.sources.ArchiveOrgCatalog
import fr.tuxgyver.cachette.sources.Catalog import fr.tuxgyver.noxely.sources.Catalog
import fr.tuxgyver.cachette.sources.CatalogId import fr.tuxgyver.noxely.sources.CatalogId
import fr.tuxgyver.cachette.sources.CatalogItem import fr.tuxgyver.noxely.sources.CatalogItem
import fr.tuxgyver.cachette.sources.WikimediaCatalog import fr.tuxgyver.noxely.sources.WikimediaCatalog
import fr.tuxgyver.cachette.sources.LibriVoxCatalog import fr.tuxgyver.noxely.sources.LibriVoxCatalog
import fr.tuxgyver.cachette.sources.RssImporter import fr.tuxgyver.noxely.sources.RssImporter
import fr.tuxgyver.cachette.sources.UrlImporter import fr.tuxgyver.noxely.sources.UrlImporter
import fr.tuxgyver.cachette.update.UpdateCheckWorker import fr.tuxgyver.noxely.update.UpdateCheckWorker
import fr.tuxgyver.cachette.update.UpdateRepository import fr.tuxgyver.noxely.update.UpdateRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -48,6 +48,10 @@ class AppContainer(context: Context) {
val catalogs: Map<CatalogId, Catalog> = listOf( val catalogs: Map<CatalogId, Catalog> = listOf(
ArchiveOrgCatalog(), ArchiveOrgCatalog(),
ArchiveOrgCatalog(
id = CatalogId.UNDERGROUND,
collections = listOf("netlabels", "freemusicarchive"),
),
LibriVoxCatalog(rssImporter), LibriVoxCatalog(rssImporter),
WikimediaCatalog(), WikimediaCatalog(),
).associateBy { it.id } ).associateBy { it.id }
@@ -63,7 +67,7 @@ class AppContainer(context: Context) {
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME) val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
} }
class CachetteApp : Application() { class NoxelyApp : Application() {
lateinit var container: AppContainer lateinit var container: AppContainer
private set private set
@@ -1,7 +1,7 @@
package fr.tuxgyver.cachette.library package fr.tuxgyver.noxely.library
import android.content.Context import android.content.Context
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -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.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted 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.ContentUris
import android.content.Context import android.content.Context
@@ -8,9 +8,9 @@ import androidx.core.net.toUri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -55,7 +55,7 @@ class LocalMediaScanner(private val context: Context) {
context.contentResolver.query( context.contentResolver.query(
collection, collection,
projection, projection,
"${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0", audioSelection(),
null, null,
null, null,
)?.use { cursor -> )?.use { cursor ->
@@ -123,6 +123,22 @@ class LocalMediaScanner(private val context: Context) {
return entries return entries
} }
/**
* 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) {
// 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
}
}
private fun albumArtUri(albumId: Long): Uri = private fun albumArtUri(albumId: Long): Uri =
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId) ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
@@ -1,7 +1,7 @@
package fr.tuxgyver.cachette.library package fr.tuxgyver.noxely.library
import android.content.Context import android.content.Context
import fr.tuxgyver.cachette.model.Playlist import fr.tuxgyver.noxely.model.Playlist
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.model package fr.tuxgyver.noxely.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.model package fr.tuxgyver.noxely.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.offline package fr.tuxgyver.noxely.offline
import android.content.Context import android.content.Context
import androidx.annotation.OptIn 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.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -110,7 +110,7 @@ class DownloadTracker(
// Ré-ajouter la même requête relance un téléchargement en échec. // Ré-ajouter la même requête relance un téléchargement en échec.
DownloadService.sendAddDownload( DownloadService.sendAddDownload(
context, context,
CachetteDownloadService::class.java, NoxelyDownloadService::class.java,
download.request, download.request,
false, false,
) )
@@ -129,15 +129,15 @@ class DownloadTracker(
val request = DownloadRequest.Builder(entry.id, entry.uri.toUri()) val request = DownloadRequest.Builder(entry.id, entry.uri.toUri())
.setData(entry.title.toByteArray()) .setData(entry.title.toByteArray())
.build() .build()
DownloadService.sendAddDownload(context, CachetteDownloadService::class.java, request, false) DownloadService.sendAddDownload(context, NoxelyDownloadService::class.java, request, false)
} }
fun remove(id: String) { fun remove(id: String) {
DownloadService.sendRemoveDownload(context, CachetteDownloadService::class.java, id, false) DownloadService.sendRemoveDownload(context, NoxelyDownloadService::class.java, id, false)
} }
fun removeAll() { fun removeAll() {
DownloadService.sendRemoveAllDownloads(context, CachetteDownloadService::class.java, false) DownloadService.sendRemoveAllDownloads(context, NoxelyDownloadService::class.java, false)
} }
private suspend fun reload() = withContext(Dispatchers.IO) { 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 android.app.Notification
import androidx.annotation.OptIn import androidx.annotation.OptIn
@@ -9,15 +9,15 @@ import androidx.media3.exoplayer.offline.DownloadService
import androidx.media3.exoplayer.scheduler.Scheduler import androidx.media3.exoplayer.scheduler.Scheduler
import androidx.media3.exoplayer.workmanager.WorkManagerScheduler import androidx.media3.exoplayer.workmanager.WorkManagerScheduler
import androidx.media3.exoplayer.offline.DownloadNotificationHelper import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import fr.tuxgyver.cachette.CachetteApp import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.cachette.R 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 * 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. * reprise après coupure réseau ou redémarrage à WorkManager.
*/ */
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class CachetteDownloadService : DownloadService( class NoxelyDownloadService : DownloadService(
NOTIFICATION_ID, NOTIFICATION_ID,
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
CHANNEL_ID, CHANNEL_ID,
@@ -29,7 +29,7 @@ class CachetteDownloadService : DownloadService(
} }
override fun getDownloadManager(): DownloadManager = override fun getDownloadManager(): DownloadManager =
(application as CachetteApp).container.offline.downloadManager (application as NoxelyApp).container.offline.downloadManager
override fun getScheduler(): Scheduler = WorkManagerScheduler(this, WORK_NAME) 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 android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
@@ -31,7 +31,7 @@ class OfflineModule(private val context: Context) {
private val httpDataSourceFactory: DataSource.Factory by lazy { private val httpDataSourceFactory: DataSource.Factory by lazy {
DefaultHttpDataSource.Factory() DefaultHttpDataSource.Factory()
.setUserAgent("Cachette/1.0 (Android)") .setUserAgent("Noxely/1.0 (Android)")
.setAllowCrossProtocolRedirects(true) .setAllowCrossProtocolRedirects(true)
.setConnectTimeoutMs(20_000) .setConnectTimeoutMs(20_000)
.setReadTimeoutMs(30_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.core.net.toUri
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata 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 * 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.app.PendingIntent
import android.content.Intent import android.content.Intent
@@ -13,8 +13,8 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import fr.tuxgyver.cachette.CachetteApp import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.cachette.MainActivity import fr.tuxgyver.noxely.MainActivity
/** Lecture audio et vidéo en arrière-plan, avec notification et commandes système. */ /** Lecture audio et vidéo en arrière-plan, avec notification et commandes système. */
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@@ -24,7 +24,7 @@ class PlaybackService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val container = (application as CachetteApp).container val container = (application as NoxelyApp).container
val player = ExoPlayer.Builder(this) val player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(container.offline.playbackDataSourceFactory())) .setMediaSourceFactory(DefaultMediaSourceFactory(container.offline.playbackDataSourceFactory()))
@@ -76,7 +76,7 @@ class PlaybackService : MediaSessionService() {
controller: MediaSession.ControllerInfo, controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>, mediaItems: MutableList<MediaItem>,
): ListenableFuture<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 -> val resolved = mediaItems.mapTo(mutableListOf()) { item ->
library.entry(item.mediaId)?.toMediaItem() ?: 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.ComponentName
import android.content.Context import android.content.Context
@@ -6,8 +6,8 @@ import androidx.core.content.ContextCompat
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -30,6 +30,49 @@ class RadioDirectory {
query("?limit=60&hidebroken=true&order=clickcount&reverse=true" + query("?limit=60&hidebroken=true&order=clickcount&reverse=true" +
"&name=${URLEncoder.encode(name.trim(), "UTF-8")}") "&name=${URLEncoder.encode(name.trim(), "UTF-8")}")
/**
* Le morceau diffusé à l'instant par une station.
*
* Aucun annuaire ne l'expose : il faut ouvrir le flux, lire le premier bloc de métadonnées
* ICY, puis refermer. C'est une connexion par station — d'où l'appel par petits paquets et
* la mise en cache par l'appelant plutôt qu'un balayage systématique.
*/
suspend fun nowPlaying(streamUrl: String): String? = withContext(Dispatchers.IO) {
runCatching {
val connection = openConnection(streamUrl, readTimeoutMs = 8_000).apply {
setRequestProperty("Icy-MetaData", "1")
}
try {
if (connection.responseCode !in 200..299) return@runCatching null
val interval = connection.getHeaderFieldInt("icy-metaint", 0)
if (interval <= 0) return@runCatching null
connection.inputStream.use { input ->
val skip = ByteArray(interval)
var filled = 0
while (filled < interval) {
val read = input.read(skip, filled, interval - filled)
if (read < 0) return@runCatching null
filled += read
}
val length = input.read().takeIf { it >= 0 }?.times(16) ?: return@runCatching null
if (length == 0) return@runCatching null
val meta = ByteArray(length)
filled = 0
while (filled < length) {
val read = input.read(meta, filled, length - filled)
if (read < 0) return@runCatching null
filled += read
}
STREAM_TITLE.find(String(meta, Charsets.UTF_8))
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
}
} finally {
connection.disconnect()
}
}.getOrNull()
}
private suspend fun query(suffix: String): Result<List<RadioStation>> = withContext(Dispatchers.IO) { private suspend fun query(suffix: String): Result<List<RadioStation>> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000) val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000)
@@ -66,6 +109,9 @@ class RadioDirectory {
private companion object { private companion object {
const val BASE = "https://de1.api.radio-browser.info/json/stations/search" const val BASE = "https://de1.api.radio-browser.info/json/stations/search"
/** Le titre court jusqu'à la fin du champ ICY : les apostrophes y sont fréquentes. */
val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
/** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */ /** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList()) fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.radio package fr.tuxgyver.noxely.radio
/** Une station de radio et son flux direct. */ /** Une station de radio et son flux direct. */
data class RadioStation( 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -10,6 +10,8 @@ data class RecordingStatus(
val bytes: Long = 0L, val bytes: Long = 0L,
/** Titre annoncé par le flux, quand la station en diffuse un. */ /** Titre annoncé par le flux, quand la station en diffuse un. */
val currentTitle: String? = null, val currentTitle: String? = null,
/** Titre attendu avant de commencer à écrire, si l'enregistrement a été programmé. */
val waitingFor: String? = null,
) { ) {
val isRecording: Boolean get() = station != null val isRecording: Boolean get() = station != null
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
@@ -25,8 +27,17 @@ object Recorder {
private val _status = MutableStateFlow(RecordingStatus()) private val _status = MutableStateFlow(RecordingStatus())
val status: StateFlow<RecordingStatus> = _status.asStateFlow() val status: StateFlow<RecordingStatus> = _status.asStateFlow()
internal fun started(station: RadioStation) { internal fun started(station: RadioStation, waitingFor: String? = null) {
_status.value = RecordingStatus(station, System.currentTimeMillis(), 0L) _status.value = RecordingStatus(station, System.currentTimeMillis(), 0L, waitingFor = waitingFor)
}
/** Le morceau attendu commence : l'écriture démarre maintenant. */
internal fun armed(title: String) {
_status.value = _status.value.copy(
waitingFor = null,
currentTitle = title,
startedAt = System.currentTimeMillis(),
)
} }
internal fun progress(bytes: Long) { internal fun progress(bytes: Long) {
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.radio package fr.tuxgyver.noxely.radio
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -11,13 +11,13 @@ import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import fr.tuxgyver.cachette.CachetteApp import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.cachette.MainActivity import fr.tuxgyver.noxely.MainActivity
import fr.tuxgyver.cachette.R import fr.tuxgyver.noxely.R
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.cachette.sources.openConnection import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -43,9 +43,12 @@ class RecordingService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null private var job: Job? = null
private var target: File? = null private var sink: RecordingSink? = null
private var sinkTitle: String = ""
private var station: RadioStation? = null private var station: RadioStation? = null
private var firstTitle: String? = null private var firstTitle: String? = null
private var trigger: String? = null
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -62,6 +65,7 @@ class RecordingService : Service() {
return START_NOT_STICKY return START_NOT_STICKY
} }
if (job?.isActive == true) return START_STICKY if (job?.isActive == true) return START_STICKY
trigger = intent.getStringExtra(EXTRA_TRIGGER)?.takeIf { it.isNotBlank() }
begin(incoming) begin(incoming)
} }
} }
@@ -71,13 +75,18 @@ class RecordingService : Service() {
private fun begin(incoming: RadioStation) { private fun begin(incoming: RadioStation) {
station = incoming station = incoming
firstTitle = null firstTitle = null
Recorder.started(incoming) Recorder.started(incoming, trigger)
startForegroundCompat(notification(incoming, 0L)) startForegroundCompat(notification(incoming, 0L))
val directory = File(getExternalFilesDir(null) ?: filesDir, "recordings").apply { mkdirs() }
val stamp = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.FRANCE).format(Date()) val stamp = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.FRANCE).format(Date())
val file = File(directory, "${incoming.name.sanitized()}-$stamp.${incoming.extension()}") val extension = incoming.extension()
target = file sinkTitle = "${incoming.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}"
val output = RecordingSink(
context = this,
fileName = "${incoming.name.sanitized()}-$stamp.$extension",
mimeType = if (extension == "aac") "audio/aac" else "audio/mpeg",
)
sink = output
job = scope.launch { job = scope.launch {
runCatching { runCatching {
@@ -93,13 +102,18 @@ class RecordingService : Service() {
} }
val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0) val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0)
connection.inputStream.use { input -> connection.inputStream.use { input ->
FileOutputStream(file).use { output -> output.open().use { stream ->
if (metaInterval > 0) { if (metaInterval > 0) {
copyWithTitles(input, output, metaInterval, incoming) copyWithTitles(input, stream, metaInterval, incoming)
} else if (trigger != null) {
// Sans métadonnées, impossible de savoir quand le morceau passe.
throw IllegalStateException(
"${incoming.name} n'annonce pas ses titres : l'attente est impossible"
)
} else { } else {
copyPlain(input, output, incoming) copyPlain(input, stream, incoming)
} }
output.flush() stream.flush()
} }
} }
} finally { } finally {
@@ -114,7 +128,7 @@ class RecordingService : Service() {
/** Flux sans métadonnées : simple recopie. */ /** Flux sans métadonnées : simple recopie. */
private suspend fun copyPlain( private suspend fun copyPlain(
input: java.io.InputStream, input: java.io.InputStream,
output: FileOutputStream, output: java.io.OutputStream,
station: RadioStation, station: RadioStation,
) { ) {
val buffer = ByteArray(32 * 1024) val buffer = ByteArray(32 * 1024)
@@ -140,18 +154,24 @@ class RecordingService : Service() {
*/ */
private suspend fun copyWithTitles( private suspend fun copyWithTitles(
input: java.io.InputStream, input: java.io.InputStream,
output: FileOutputStream, output: java.io.OutputStream,
interval: Int, interval: Int,
station: RadioStation, station: RadioStation,
) { ) {
val wanted = trigger
val audio = ByteArray(interval) val audio = ByteArray(interval)
var total = 0L var total = 0L
var lastNotified = 0L var lastNotified = 0L
// Sans titre attendu, on écrit dès la première seconde ; sinon on patiente.
var writing = wanted == null
var capturedTitle: String? = null
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
if (!input.readFully(audio, interval)) break if (!input.readFully(audio, interval)) break
output.write(audio) if (writing) {
total += interval output.write(audio)
total += interval
}
val lengthByte = input.read() val lengthByte = input.read()
if (lengthByte < 0) break if (lengthByte < 0) break
@@ -159,16 +179,32 @@ class RecordingService : Service() {
if (metaLength > 0) { if (metaLength > 0) {
val meta = ByteArray(metaLength) val meta = ByteArray(metaLength)
if (!input.readFully(meta, metaLength)) break if (!input.readFully(meta, metaLength)) break
STREAM_TITLE val announced = STREAM_TITLE
.find(String(meta, Charsets.UTF_8)) .find(String(meta, Charsets.UTF_8))
?.groupValues?.get(1) ?.groupValues?.get(1)
?.trim() ?.trim()
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.let { title ->
if (firstTitle == null) firstTitle = title if (announced != null) {
Recorder.title(title) Recorder.title(announced)
notifyProgress(station, total) when {
wanted == null -> if (firstTitle == null) firstTitle = announced
!writing && announced.contains(wanted, ignoreCase = true) -> {
// Le morceau attendu commence : on enregistre à partir d'ici.
writing = true
capturedTitle = announced
firstTitle = announced
Recorder.armed(announced)
}
writing && capturedTitle != null && announced != capturedTitle -> {
// Le morceau est terminé : on s'arrête sans attendre le suivant.
break
}
} }
notifyProgress(station, total)
}
} }
if (total - lastNotified > NOTIFY_EVERY_BYTES) { if (total - lastNotified > NOTIFY_EVERY_BYTES) {
@@ -196,34 +232,38 @@ class RecordingService : Service() {
job?.cancel() job?.cancel()
job = null job = null
val file = target val output = sink
val recorded = station val recorded = station
target = null
station = null
val captured = firstTitle val captured = firstTitle
sink = null
station = null
trigger = null
if (file != null && recorded != null && file.length() > MIN_USEFUL_BYTES) { if (output != null && recorded != null) {
val elapsed = Recorder.status.value.elapsedMs val elapsed = Recorder.status.value.elapsedMs
val container = (application as CachetteApp).container // Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant.
val entry = MediaEntry( val title = captured ?: sinkTitle
id = "rec:${file.name}", val published = output.finish(title, elapsed)
source = SourceKind.RECORDING, if (published != null) {
kind = MediaKind.AUDIO, val (uri, size) = published
// Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant. val container = (application as NoxelyApp).container
title = captured val entry = MediaEntry(
?: "${recorded.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}", id = "rec:${uri.hashCode()}",
subtitle = if (captured != null) recorded.name else "Enregistrement radio", source = SourceKind.RECORDING,
uri = file.toURI().toString(), kind = MediaKind.AUDIO,
artworkUri = recorded.faviconUrl, title = title,
durationMs = elapsed, subtitle = if (captured != null) recorded.name else "Enregistrement radio",
addedAt = System.currentTimeMillis(), uri = uri,
sizeBytes = file.length(), artworkUri = recorded.faviconUrl,
) durationMs = elapsed,
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) } addedAt = System.currentTimeMillis(),
} else { sizeBytes = size,
file?.delete() )
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) }
}
} }
firstTitle = null
Recorder.stopped() Recorder.stopped()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
@@ -267,7 +307,10 @@ class RecordingService : Service() {
) )
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline) .setSmallIcon(R.drawable.ic_offline)
.setContentTitle("Enregistrement de ${station.name}") .setContentTitle(
if (Recorder.status.value.waitingFor != null) "En attente sur ${station.name}"
else "Enregistrement de ${station.name}"
)
.setContentText( .setContentText(
Recorder.status.value.currentTitle Recorder.status.value.currentTitle
?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…" ?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…"
@@ -300,21 +343,22 @@ class RecordingService : Service() {
replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(' ', '_').take(40).ifBlank { "radio" } replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(' ', '_').take(40).ifBlank { "radio" }
companion object { companion object {
private const val ACTION_START = "fr.tuxgyver.cachette.RECORD_START" private const val ACTION_START = "fr.tuxgyver.noxely.RECORD_START"
private const val ACTION_STOP = "fr.tuxgyver.cachette.RECORD_STOP" private const val ACTION_STOP = "fr.tuxgyver.noxely.RECORD_STOP"
private const val EXTRA_ID = "id" private const val EXTRA_ID = "id"
private const val EXTRA_NAME = "name" private const val EXTRA_NAME = "name"
private const val EXTRA_URL = "url" private const val EXTRA_URL = "url"
private const val EXTRA_CODEC = "codec" private const val EXTRA_CODEC = "codec"
private const val EXTRA_BITRATE = "bitrate" private const val EXTRA_BITRATE = "bitrate"
private const val EXTRA_FAVICON = "favicon" private const val EXTRA_FAVICON = "favicon"
private const val EXTRA_TRIGGER = "trigger"
private const val NOTIFICATION_ID = 4401 private const val NOTIFICATION_ID = 4401
private const val CHANNEL_ID = "cachette_recording" private const val CHANNEL_ID = "cachette_recording"
private const val NOTIFY_EVERY_BYTES = 256 * 1024L private const val NOTIFY_EVERY_BYTES = 256 * 1024L
private const val MIN_USEFUL_BYTES = 16 * 1024L private const val MIN_USEFUL_BYTES = 16 * 1024L
private val STREAM_TITLE = Regex("StreamTitle='(.*?)';") private val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
fun start(context: Context, station: RadioStation) { fun start(context: Context, station: RadioStation, whenTitleContains: String? = null) {
val intent = Intent(context, RecordingService::class.java) val intent = Intent(context, RecordingService::class.java)
.setAction(ACTION_START) .setAction(ACTION_START)
.putExtra(EXTRA_ID, station.id) .putExtra(EXTRA_ID, station.id)
@@ -323,6 +367,7 @@ class RecordingService : Service() {
.putExtra(EXTRA_CODEC, station.codec) .putExtra(EXTRA_CODEC, station.codec)
.putExtra(EXTRA_BITRATE, station.bitrate) .putExtra(EXTRA_BITRATE, station.bitrate)
.putExtra(EXTRA_FAVICON, station.faviconUrl) .putExtra(EXTRA_FAVICON, station.faviconUrl)
.putExtra(EXTRA_TRIGGER, whenTitleContains)
context.startForegroundService(intent) context.startForegroundService(intent)
} }
@@ -0,0 +1,116 @@
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.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/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é.
*/
class RecordingSink(
private val context: Context,
private val fileName: String,
private val mimeType: String,
) {
private val useMediaStore = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
private var uri: Uri? = null
private var file: File? = null
fun open(): OutputStream = if (useMediaStore) openMediaStore() else openPrivateFile()
private fun openMediaStore(): OutputStream {
val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val values = ContentValues().apply {
put(MediaStore.Audio.Media.DISPLAY_NAME, fileName)
put(MediaStore.Audio.Media.MIME_TYPE, mimeType)
put(MediaStore.Audio.Media.RELATIVE_PATH, RELATIVE_PATH)
// Tant que l'écriture dure, le fichier reste invisible des autres applications.
put(MediaStore.Audio.Media.IS_PENDING, 1)
}
val created = context.contentResolver.insert(collection, values)
?: throw IllegalStateException("Impossible de créer le fichier dans la bibliothèque système")
uri = created
return context.contentResolver.openOutputStream(created)
?: throw IllegalStateException("Impossible d'écrire dans la bibliothèque système")
}
private fun openPrivateFile(): OutputStream {
val directory = File(context.getExternalFilesDir(null) ?: context.filesDir, "recordings")
.apply { mkdirs() }
val created = File(directory, fileName)
file = created
return created.outputStream()
}
/** Publie l'enregistrement sous son titre définitif, ou renvoie null s'il est vide. */
fun finish(title: String, durationMs: Long): Pair<String, Long>? {
val target = uri
if (target != null) {
val size = context.contentResolver.openFileDescriptor(target, "r")
?.use { it.statSize } ?: 0L
if (size < MIN_USEFUL_BYTES) {
abort()
return null
}
// En levant IS_PENDING, MediaStore relit le fichier et réécrit TITLE depuis le nom :
// c'est donc le nom de fichier, et lui seul, qui doit porter le titre du morceau.
// (Vérifié : un TITLE posé ici est écrasé par ce balayage.)
val extension = fileName.substringAfterLast('.', "mp3")
context.contentResolver.update(
target,
ContentValues().apply {
put(MediaStore.Audio.Media.IS_PENDING, 0)
put(MediaStore.Audio.Media.DISPLAY_NAME, "${title.asFileName()}.$extension")
if (durationMs > 0) put(MediaStore.Audio.Media.DURATION, durationMs)
},
null,
null,
)
return target.toString() to size
}
val created = file ?: return null
if (created.length() < MIN_USEFUL_BYTES) {
created.delete()
return null
}
return created.toURI().toString() to created.length()
}
fun abort() {
uri?.let { context.contentResolver.delete(it, null, null) }
file?.delete()
uri = null
file = null
}
/** Un titre de morceau n'est pas un nom de fichier : on en retire ce qui gênerait. */
private fun String.asFileName(): String =
replace(Regex("[^\\p{L}\\p{N} ._-]"), "").trim().take(80).ifBlank { "enregistrement" }
companion object {
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. */
fun delete(context: Context, entry: MediaEntry): Boolean = runCatching {
val uri = Uri.parse(entry.uri)
when (uri.scheme) {
"content" -> context.contentResolver.delete(uri, null, null) > 0
"file" -> File(uri.path!!).delete()
else -> false
}
}.getOrDefault(false)
}
}
@@ -1,8 +1,8 @@
package fr.tuxgyver.cachette.sources package fr.tuxgyver.noxely.sources
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -19,9 +19,16 @@ import java.net.URLEncoder
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de * qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
* trente éléments dont vingt-neuf seront ignorés. * trente éléments dont vingt-neuf seront ignorés.
*/ */
class ArchiveOrgCatalog : Catalog { class ArchiveOrgCatalog(
override val id: CatalogId = CatalogId.ARCHIVE,
override val id = CatalogId.ARCHIVE /**
* Collections auxquelles se restreindre. Vide : tout archive.org.
* `netlabels` et `freemusicarchive` ne contiennent que des publications sous licence libre,
* ce qui donne un fonds indépendant sans les dépôts de titres commerciaux qu'on croise
* ailleurs sur le site.
*/
private val collections: List<String> = emptyList(),
) : Catalog {
override val languages = COMMON_LANGUAGES override val languages = COMMON_LANGUAGES
@@ -33,8 +40,13 @@ class ArchiveOrgCatalog : Catalog {
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte // Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
// dès la recherche plutôt que de les proposer puis d'échouer. // dès la recherche plutôt que de les proposer puis d'échouer.
val languageClause = language?.let { " AND language:($it)" }.orEmpty() val languageClause = language?.let { " AND language:($it)" }.orEmpty()
val collectionClause =
collections.takeIf { it.isNotEmpty() }
?.joinToString(" OR ")
?.let { " AND collection:($it)" }
.orEmpty()
val q = URLEncoder.encode( val q = URLEncoder.encode(
"($query) AND mediatype:(audio OR movies)$languageClause" + "($query) AND mediatype:(audio OR movies)$languageClause$collectionClause" +
" AND NOT access-restricted-item:true", " AND NOT access-restricted-item:true",
"UTF-8", "UTF-8",
) )
@@ -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. */ /** Un résultat de recherche : un album, un livre ou un morceau, pas encore un fichier. */
data class CatalogItem( data class CatalogItem(
@@ -52,6 +52,12 @@ enum class CatalogId(
license = "Domaine public", license = "Domaine public",
suggestion = "Jules Verne", suggestion = "Jules Verne",
), ),
UNDERGROUND(
title = "Underground",
description = "Netlabels et Free Music Archive : des dizaines de milliers de morceaux d'artistes indépendants, publiés sous licence libre.",
license = "Creative Commons / netlabels",
suggestion = "ambient",
),
WIKIMEDIA( WIKIMEDIA(
title = "Wikimedia Commons", title = "Wikimedia Commons",
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.", description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.sources package fr.tuxgyver.noxely.sources
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@@ -19,5 +19,5 @@ internal fun openConnection(
instanceFollowRedirects = true instanceFollowRedirects = true
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = readTimeoutMs 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.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -1,9 +1,9 @@
package fr.tuxgyver.cachette.sources package fr.tuxgyver.noxely.sources
import android.util.Xml import android.util.Xml
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
@@ -1,10 +1,10 @@
package fr.tuxgyver.cachette.sources package fr.tuxgyver.noxely.sources
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json 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.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -35,22 +35,23 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.cachette.sources.CatalogId import fr.tuxgyver.noxely.sources.CatalogId
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen import fr.tuxgyver.noxely.ui.about.AboutScreen
import fr.tuxgyver.cachette.ui.browse.CatalogItemScreen import fr.tuxgyver.noxely.ui.browse.CatalogBrowseScreen
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen import fr.tuxgyver.noxely.ui.browse.CatalogItemScreen
import fr.tuxgyver.cachette.ui.library.LibraryScreen import fr.tuxgyver.noxely.ui.browse.SourceBrowseScreen
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen import fr.tuxgyver.noxely.ui.library.LibraryScreen
import fr.tuxgyver.cachette.ui.player.MiniPlayer import fr.tuxgyver.noxely.ui.offline.DownloadsScreen
import fr.tuxgyver.cachette.ui.radio.RadioScreen import fr.tuxgyver.noxely.ui.player.MiniPlayer
import fr.tuxgyver.cachette.ui.playlists.AddToPlaylistSheet import fr.tuxgyver.noxely.ui.radio.RadioScreen
import fr.tuxgyver.cachette.ui.playlists.PlaylistDetailScreen import fr.tuxgyver.noxely.ui.playlists.AddToPlaylistSheet
import fr.tuxgyver.cachette.ui.playlists.PlaylistsScreen import fr.tuxgyver.noxely.ui.playlists.PlaylistDetailScreen
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen import fr.tuxgyver.noxely.ui.playlists.PlaylistsScreen
import fr.tuxgyver.cachette.ui.sources.SourcesScreen import fr.tuxgyver.noxely.ui.player.NowPlayingScreen
import fr.tuxgyver.noxely.ui.sources.SourcesScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private enum class Tab(val route: String, val label: String, val icon: ImageVector) { private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
@@ -65,9 +66,10 @@ private const val CATALOG_ROUTE = "catalog/{catalog}"
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item" private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
private const val PLAYLIST_ROUTE = "playlist/{playlist}" private const val PLAYLIST_ROUTE = "playlist/{playlist}"
private const val RADIO_ROUTE = "radio" private const val RADIO_ROUTE = "radio"
private const val ABOUT_ROUTE = "about"
@Composable @Composable
fun CachetteRoot(container: AppContainer) { fun NoxelyRoot(container: AppContainer) {
val navController = rememberNavController() val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = backStackEntry?.destination?.route
@@ -145,6 +147,10 @@ fun CachetteRoot(container: AppContainer) {
) )
} }
composable(ABOUT_ROUTE) {
AboutScreen(onBack = navController::popBackStack, contentPadding = padding)
}
composable(RADIO_ROUTE) { composable(RADIO_ROUTE) {
RadioScreen( RadioScreen(
container = container, container = container,
@@ -174,6 +180,7 @@ fun CachetteRoot(container: AppContainer) {
onBrowseSource = { navController.navigate("browse/${it.name}") }, onBrowseSource = { navController.navigate("browse/${it.name}") },
onBrowseCatalog = { navController.navigate("catalog/${it.name}") }, onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
onBrowseRadio = { navController.navigate(RADIO_ROUTE) }, 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.clickable
import androidx.compose.foundation.horizontalScroll 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.sources.CatalogId import fr.tuxgyver.noxely.sources.CatalogId
import fr.tuxgyver.cachette.sources.CatalogItem import fr.tuxgyver.noxely.sources.CatalogItem
import fr.tuxgyver.cachette.ui.components.Artwork import fr.tuxgyver.noxely.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatBytes import fr.tuxgyver.noxely.ui.components.formatBytes
import kotlinx.coroutines.launch 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.clickable
import androidx.compose.foundation.layout.Arrangement 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.sources.CatalogId import fr.tuxgyver.noxely.sources.CatalogId
import fr.tuxgyver.cachette.ui.components.formatBytes import fr.tuxgyver.noxely.ui.components.formatBytes
import fr.tuxgyver.cachette.ui.components.formatDuration import fr.tuxgyver.noxely.ui.components.formatDuration
import kotlinx.coroutines.launch 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.AnimatedVisibility
import androidx.compose.animation.slideInVertically 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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.cachette.ui.components.MediaRow 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, * 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.graphics.Bitmap
import android.os.Build import android.os.Build
@@ -28,7 +28,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri import androidx.core.net.toUri
import coil.compose.AsyncImage import coil.compose.AsyncImage
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.components package fr.tuxgyver.noxely.ui.components
import java.util.Locale 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.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -32,8 +32,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.offline.OfflineState import fr.tuxgyver.noxely.offline.OfflineState
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.library.LocalMediaScanner import fr.tuxgyver.noxely.library.LocalMediaScanner
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.offline.OfflineState import fr.tuxgyver.noxely.offline.OfflineState
import fr.tuxgyver.cachette.ui.components.MediaRow import fr.tuxgyver.noxely.ui.components.MediaRow
private enum class LibraryFilter(val label: String) { private enum class LibraryFilter(val label: String) {
ALL("Tout"), ALL("Tout"),
@@ -201,7 +201,7 @@ private fun PermissionCard(onGrant: () -> Unit) {
Text("Accéder à vos médias", style = MaterialTheme.typography.titleMedium) Text("Accéder à vos médias", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.size(6.dp)) Spacer(Modifier.size(6.dp))
Text( 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, style = MaterialTheme.typography.bodyMedium,
) )
Spacer(Modifier.size(14.dp)) 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -14,8 +14,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -27,18 +29,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.cachette.ui.components.MediaRow import fr.tuxgyver.noxely.radio.RecordingSink
import fr.tuxgyver.cachette.ui.components.formatBytes import fr.tuxgyver.noxely.ui.components.MediaRow
import fr.tuxgyver.noxely.ui.components.formatBytes
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@Composable @Composable
@@ -52,6 +58,9 @@ fun DownloadsScreen(
val entries by container.library.entries.collectAsStateWithLifecycle() val entries by container.library.entries.collectAsStateWithLifecycle()
val offlineStates by container.downloads.states.collectAsStateWithLifecycle() val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
var confirmClear by remember { mutableStateOf(false) } var confirmClear by remember { mutableStateOf(false) }
var pendingRecordingDelete by remember { mutableStateOf<MediaEntry?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var cacheSize by remember { mutableLongStateOf(0L) } var cacheSize by remember { mutableLongStateOf(0L) }
// Un enregistrement radio n'a pas d'état de téléchargement : le fichier est déjà là. // Un enregistrement radio n'a pas d'état de téléchargement : le fichier est déjà là.
@@ -101,6 +110,8 @@ fun DownloadsScreen(
items(cached, key = { it.id }) { entry -> items(cached, key = { it.id }) { entry ->
val state = offlineStates[entry.id] val state = offlineStates[entry.id]
Column { Column {
val isRecording = entry.source == SourceKind.RECORDING
Row(verticalAlignment = Alignment.CenterVertically) {
MediaRow( MediaRow(
entry = entry, entry = entry,
offline = state, offline = state,
@@ -109,7 +120,17 @@ fun DownloadsScreen(
onDownload = { container.downloads.download(entry) }, onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) }, onRemoveOffline = { container.downloads.remove(entry.id) },
onLongClick = { onAddToPlaylist(entry) }, onLongClick = { onAddToPlaylist(entry) },
modifier = Modifier.weight(1f),
) )
if (isRecording) {
IconButton(onClick = { pendingRecordingDelete = entry }) {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = "Supprimer cet enregistrement",
)
}
}
}
if (state?.isActive == true) { if (state?.isActive == true) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { (state.percent / 100f).coerceIn(0f, 1f) }, progress = { (state.percent / 100f).coerceIn(0f, 1f) },
@@ -122,6 +143,26 @@ fun DownloadsScreen(
} }
} }
pendingRecordingDelete?.let { entry ->
AlertDialog(
onDismissRequest = { pendingRecordingDelete = null },
title = { Text("Supprimer cet enregistrement ?") },
text = { Text("« ${entry.title} » sera effacé de l'appareil. Cette action est définitive : un enregistrement ne se retélécharge pas.") },
confirmButton = {
TextButton(onClick = {
scope.launch {
RecordingSink.delete(context, entry)
container.library.forget(entry.id)
}
pendingRecordingDelete = null
}) { Text("Supprimer") }
},
dismissButton = {
TextButton(onClick = { pendingRecordingDelete = null }) { Text("Annuler") }
},
)
}
if (confirmClear) { if (confirmClear) {
AlertDialog( AlertDialog(
onDismissRequest = { confirmClear = false }, onDismissRequest = { confirmClear = false },
@@ -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.basicMarquee
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -26,8 +26,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.playback.PlayerUiState import fr.tuxgyver.noxely.playback.PlayerUiState
import fr.tuxgyver.cachette.ui.components.Artwork import fr.tuxgyver.noxely.ui.components.Artwork
@Composable @Composable
fun MiniPlayer( fun MiniPlayer(
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.player package fr.tuxgyver.noxely.ui.player
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -54,12 +54,12 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.offline.OfflineState import fr.tuxgyver.noxely.offline.OfflineState
import fr.tuxgyver.cachette.playback.PlayerUiState import fr.tuxgyver.noxely.playback.PlayerUiState
import fr.tuxgyver.cachette.ui.components.Artwork import fr.tuxgyver.noxely.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatDuration import fr.tuxgyver.noxely.ui.components.formatDuration
@Composable @Composable
fun NowPlayingScreen( fun NowPlayingScreen(
@@ -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.clickable
import androidx.compose.foundation.layout.Arrangement 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** Choix de la playlist d'accueil, avec création à la volée si aucune ne convient. */ /** 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.Arrangement
import androidx.compose.foundation.layout.Box 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.ui.components.MediaRow import fr.tuxgyver.noxely.ui.components.MediaRow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** Le contenu d'une playlist, dans l'ordre d'ajout. */ /** Le contenu d'une playlist, dans l'ordre d'ajout. */
@@ -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.clickable
import androidx.compose.foundation.layout.Arrangement 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @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.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
@@ -22,11 +22,13 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.FiberManualRecord 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.GraphicEq
import androidx.compose.material.icons.rounded.PlayCircleOutline import androidx.compose.material.icons.rounded.PlayCircleOutline
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Stop import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
@@ -36,6 +38,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -54,18 +57,20 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.cachette.radio.RADIO_GENRES import fr.tuxgyver.noxely.radio.RADIO_GENRES
import fr.tuxgyver.cachette.radio.RadioStation import fr.tuxgyver.noxely.radio.RadioStation
import fr.tuxgyver.cachette.radio.Recorder import fr.tuxgyver.noxely.radio.Recorder
import fr.tuxgyver.cachette.radio.RecordingService import fr.tuxgyver.noxely.radio.RecordingService
import fr.tuxgyver.cachette.ui.components.Artwork import fr.tuxgyver.noxely.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatBytes import fr.tuxgyver.noxely.ui.components.formatBytes
import fr.tuxgyver.cachette.ui.components.formatDuration import fr.tuxgyver.noxely.ui.components.formatDuration
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -92,6 +97,8 @@ fun RadioScreen(
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) } var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var nowPlaying by remember { mutableStateOf(emptyMap<String, String>()) }
var scheduleFor by remember { mutableStateOf<RadioStation?>(null) }
fun load() { fun load() {
loading = true loading = true
@@ -109,6 +116,20 @@ fun RadioScreen(
LaunchedEffect(genre) { load() } LaunchedEffect(genre) { load() }
// Aucun annuaire n'expose le morceau en cours : il faut ouvrir chaque flux. On s'en tient
// donc aux premières stations affichées, quatre à la fois, et le résultat est conservé.
LaunchedEffect(stations) {
nowPlaying = emptyMap()
stations.take(PROBED_STATIONS).chunked(4).forEach { group ->
val found = group.map { station ->
async { station.id to container.radio.nowPlaying(station.streamUrl) }
}.awaitAll()
nowPlaying = nowPlaying + found.mapNotNull { (id, title) ->
title?.let { id to it }
}
}
}
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()) val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -134,6 +155,7 @@ fun RadioScreen(
RecordingBanner( RecordingBanner(
station = status.station!!, station = status.station!!,
title = status.currentTitle, title = status.currentTitle,
waitingFor = status.waitingFor,
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L), elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
bytes = status.bytes, bytes = status.bytes,
onStop = { onStop = {
@@ -206,8 +228,10 @@ fun RadioScreen(
items(stations, key = { it.id }) { station -> items(stations, key = { it.id }) { station ->
StationRow( StationRow(
station = station, station = station,
nowPlaying = nowPlaying[station.id],
playing = station.asEntry().id == currentMediaId, playing = station.asEntry().id == currentMediaId,
recording = status.station?.id == station.id, recording = status.station?.id == station.id,
onSchedule = { scheduleFor = station },
onListen = { onListen = {
val entry = station.asEntry() val entry = station.asEntry()
container.library.preview(listOf(entry)) container.library.preview(listOf(entry))
@@ -227,8 +251,23 @@ fun RadioScreen(
} }
} }
} }
scheduleFor?.let { station ->
ScheduleDialog(
station = station,
onDismiss = { scheduleFor = null },
onConfirm = { wanted ->
RecordingService.start(context, station, whenTitleContains = wanted)
onMessage("Noxely guette « $wanted » sur ${station.name}")
scheduleFor = null
},
)
}
} }
/** Au-delà, le nombre de connexions simultanées ne se justifierait plus. */
private const val PROBED_STATIONS = 12
/** Une station écoutée en direct est un média éphémère : pas de durée, pas de mise en cache. */ /** Une station écoutée en direct est un média éphémère : pas de durée, pas de mise en cache. */
private fun RadioStation.asEntry() = MediaEntry( private fun RadioStation.asEntry() = MediaEntry(
id = "radio:$id", id = "radio:$id",
@@ -240,10 +279,47 @@ private fun RadioStation.asEntry() = MediaEntry(
artworkUri = faviconUrl, artworkUri = faviconUrl,
) )
/** Programme un enregistrement : il démarrera dès que la station annoncera ce titre. */
@Composable
private fun ScheduleDialog(
station: RadioStation,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var wanted by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Guetter un titre") },
text = {
Column {
Text(
text = "Noxely écoutera ${station.name} et déclenchera l'enregistrement dès " +
"qu'un titre contenant ce texte sera annoncé, puis s'arrêtera à la fin du morceau.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = wanted,
onValueChange = { wanted = it },
placeholder = { Text("Titre ou artiste") },
singleLine = true,
)
}
},
confirmButton = {
TextButton(enabled = wanted.isNotBlank(), onClick = { onConfirm(wanted.trim()) }) {
Text("Guetter")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
)
}
@Composable @Composable
private fun RecordingBanner( private fun RecordingBanner(
station: RadioStation, station: RadioStation,
title: String?, title: String?,
waitingFor: String?,
elapsedMs: Long, elapsedMs: Long,
bytes: Long, bytes: Long,
onStop: () -> Unit, onStop: () -> Unit,
@@ -267,7 +343,7 @@ private fun RecordingBanner(
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
text = title ?: station.name, text = waitingFor?.let { "En attente de « $it »" } ?: title ?: station.name,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -295,10 +371,12 @@ private fun RecordingBanner(
@Composable @Composable
private fun StationRow( private fun StationRow(
station: RadioStation, station: RadioStation,
nowPlaying: String?,
playing: Boolean, playing: Boolean,
recording: Boolean, recording: Boolean,
onListen: () -> Unit, onListen: () -> Unit,
onRecord: () -> Unit, onRecord: () -> Unit,
onSchedule: () -> Unit,
) { ) {
Surface( Surface(
color = if (playing) MaterialTheme.colorScheme.primaryContainer color = if (playing) MaterialTheme.colorScheme.primaryContainer
@@ -328,9 +406,10 @@ private fun StationRow(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = station.subtitle, text = nowPlaying ?: station.subtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = if (nowPlaying != null) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -341,6 +420,13 @@ private fun StationRow(
tint = if (playing) MaterialTheme.colorScheme.primary tint = if (playing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
) )
IconButton(onClick = onSchedule) {
Icon(
Icons.Rounded.Alarm,
contentDescription = "Enregistrer quand un titre passe",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRecord) { IconButton(onClick = onRecord) {
Icon( Icon(
Icons.Rounded.FiberManualRecord, Icons.Rounded.FiberManualRecord,
@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.MenuBook 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.LibraryMusic
import androidx.compose.material.icons.rounded.Public import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Radio import androidx.compose.material.icons.rounded.Radio
@@ -48,9 +50,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.model.SourceKind import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.cachette.sources.CatalogId import fr.tuxgyver.noxely.sources.CatalogId
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -61,6 +63,7 @@ fun SourcesScreen(
onBrowseSource: (SourceKind) -> Unit, onBrowseSource: (SourceKind) -> Unit,
onBrowseCatalog: (CatalogId) -> Unit, onBrowseCatalog: (CatalogId) -> Unit,
onBrowseRadio: () -> Unit, onBrowseRadio: () -> Unit,
onOpenAbout: () -> Unit,
) { ) {
val entries by container.library.entries.collectAsStateWithLifecycle() val entries by container.library.entries.collectAsStateWithLifecycle()
var sheet by remember { mutableStateOf<ImportKind?>(null) } var sheet by remember { mutableStateOf<ImportKind?>(null) }
@@ -152,6 +155,7 @@ fun SourcesScreen(
icon = when (catalog) { icon = when (catalog) {
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
CatalogId.UNDERGROUND -> Icons.Rounded.Headphones
else -> Icons.Rounded.Public else -> Icons.Rounded.Public
}, },
title = catalog.title, title = catalog.title,
@@ -174,6 +178,18 @@ fun SourcesScreen(
UpdateCard(container) 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( unavailableSourceCards(
listOf( listOf(
UnavailableSource( 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -24,10 +24,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.AppContainer import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.cachette.BuildConfig import fr.tuxgyver.noxely.BuildConfig
import fr.tuxgyver.cachette.update.UpdateInfo import fr.tuxgyver.noxely.update.UpdateInfo
import fr.tuxgyver.cachette.update.UpdateNotifier import fr.tuxgyver.noxely.update.UpdateNotifier
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -82,7 +82,7 @@ fun UpdateCard(container: AppContainer) {
Text( Text(
text = when (current) { text = when (current) {
UpdateUiState.Idle -> 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.Checking -> "Vérification en cours…"
UpdateUiState.UpToDate -> "Vous avez déjà la dernière version." UpdateUiState.UpToDate -> "Vous avez déjà la dernière version."
is UpdateUiState.Available -> "Version ${current.info.version} disponible." 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.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -57,7 +57,7 @@ private val DarkColors = darkColorScheme(
outlineVariant = Color(0xFF3A3048), outlineVariant = Color(0xFF3A3048),
) )
private val CachetteShapes = Shapes( private val NoxelyShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp), extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(14.dp), small = RoundedCornerShape(14.dp),
medium = RoundedCornerShape(20.dp), medium = RoundedCornerShape(20.dp),
@@ -65,7 +65,7 @@ private val CachetteShapes = Shapes(
extraLarge = RoundedCornerShape(36.dp), extraLarge = RoundedCornerShape(36.dp),
) )
private val CachetteTypography = Typography().run { private val NoxelyTypography = Typography().run {
copy( copy(
displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp), displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp),
headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.4).sp), headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.4).sp),
@@ -77,7 +77,7 @@ private val CachetteTypography = Typography().run {
} }
@Composable @Composable
fun CachetteTheme( fun NoxelyTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
@@ -87,8 +87,8 @@ fun CachetteTheme(
val colors = if (darkTheme) DarkColors else LightColors val colors = if (darkTheme) DarkColors else LightColors
MaterialTheme( MaterialTheme(
colorScheme = colors, colorScheme = colors,
shapes = CachetteShapes, shapes = NoxelyShapes,
typography = CachetteTypography, typography = NoxelyTypography,
content = content, content = content,
) )
} }
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.update package fr.tuxgyver.noxely.update
import android.content.Context import android.content.Context
import androidx.work.Constraints import androidx.work.Constraints
@@ -8,7 +8,7 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import fr.tuxgyver.cachette.CachetteApp import fr.tuxgyver.noxely.NoxelyApp
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -21,7 +21,7 @@ class UpdateCheckWorker(
) : CoroutineWorker(context, params) { ) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result { 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() val update = runCatching { repository.checkForUpdate() }.getOrNull()
?: return Result.success() ?: return Result.success()
val apk = runCatching { repository.downloadApk(update) }.getOrNull() 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.Manifest
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -12,7 +12,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import fr.tuxgyver.cachette.R import fr.tuxgyver.noxely.R
import java.io.File import java.io.File
/** Prévient que la mise à jour est téléchargée et ouvre l'installateur système d'un appui. */ /** 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) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline) .setSmallIcon(R.drawable.ic_offline)
.setContentTitle("Cachette $version est prête") .setContentTitle("Noxely $version est prête")
.setContentText("Appuyez pour installer la mise à jour.") .setContentText("Appuyez pour installer la mise à jour.")
.setContentIntent(pending) .setContentIntent(pending)
.setAutoCancel(true) .setAutoCancel(true)
@@ -73,7 +73,7 @@ object UpdateNotifier {
CHANNEL_ID, CHANNEL_ID,
"Mises à jour", "Mises à jour",
NotificationManager.IMPORTANCE_DEFAULT, 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) 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 android.content.Context
import fr.tuxgyver.cachette.BuildConfig import fr.tuxgyver.noxely.BuildConfig
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -103,7 +103,7 @@ class UpdateRepository(
instanceFollowRedirects = true instanceFollowRedirects = true
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 60_000 readTimeout = 60_000
setRequestProperty("User-Agent", "Cachette/$currentVersion (Android)") setRequestProperty("User-Agent", "Noxely/$currentVersion (Android)")
setRequestProperty("Authorization", "token ${BuildConfig.GITEA_UPDATE_TOKEN}") 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"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </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> <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:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
+1 -1
View File
@@ -1,4 +1,4 @@
<resources> <resources>
<color name="splash_background">#FFF7F2FA</color> <color name="splash_background">#FFF7F2FA</color>
<color name="ic_launcher_background">#FF1B1033</color> <color name="ic_launcher_background">#FF0E9CC7</color>
</resources> </resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<resources> <resources>
<string name="app_name">Cachette</string> <string name="app_name">Noxely</string>
<string name="download_channel_name">Téléchargements</string> <string name="download_channel_name">Téléchargements</string>
</resources> </resources>
+1 -1
View File
@@ -1,5 +1,5 @@
<resources> <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:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@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") include(":app")