4 Commits
Author SHA1 Message Date
tuxgyver ed1d40826b Noxely se sépare en client et serveur (2.0.0)
Le téléphone balayait quarante radios toutes les trois minutes, cherchait dans
quatre catalogues et enregistrait des directs. Tout cela le tenait éveillé pour
un travail qui n'a aucune raison de se faire sur batterie.

Un serveur reprend la collecte : recherche, guet, enregistrement, file de
téléchargement, piloté depuis un navigateur. Le mobile demande ce qui a changé
depuis son curseur, télécharge et se rendort — toutes les six heures, en Wi-Fi
non facturé. Vingt fichiers quittent l'application au passage.

Le balayage devient continu plutôt qu'espacé de trois minutes : une sonde ne lit
que le premier bloc de métadonnées avant de refermer, ce qui rend la permanence
tenable quand garder les flux ouverts coûterait plusieurs mégabits par seconde.
Un morceau guetté a été capté en moins de vingt-cinq secondes. La recherche
interroge les quatre catalogues à la fois et fond leurs résultats, les plus
légers d'abord.

Quatre défauts corrigés en chemin, tous trouvés en essayant plutôt qu'en
relisant : un jeton régénéré à chaque démarrage, qui aurait obligé à
reconfigurer le mobile ; un 403 de Wikimedia dû à un agent trop générique, que
la fusion des sources masquait ; un dossier de données par défaut inaccessible
au compte qui lance le serveur ; et une lecture de métadonnées qui n'itérait
rien, faute d'être asynchrone.
2026-09-13 17:39:43 +02:00
tuxgyver adc6fc0486 Guet sur toutes les stations, veille de fond et pochettes (1.7.0)
Guetter un morceau supposait de savoir sur quelle station il passerait. On le
nomme désormais sans plus : les quarante stations les plus écoutées sont
balayées quatre à la fois, et l'enregistrement part dès que l'une le diffuse.
Un écran sépare ce qu'on attend de ce qu'on a capté.

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

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

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

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

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

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

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

Le .gitignore filtre désormais *.apk, et non un motif au nom du projet : c'est
ce motif nommé qui, au renommage, avait laissé entrer 95 Mo d'anciens APK.
2026-09-13 14:00:34 +02:00
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
90 changed files with 3219 additions and 2947 deletions

No files matched your search

+7 -1
View File
@@ -10,5 +10,11 @@ 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
server/venv/
server/data/
server/.env
server/**/__pycache__/
+135 -2
View File
@@ -1,10 +1,143 @@
# 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/).
## [2.0.0] — 2026-09-13
Noxely se sépare en deux : un **serveur** qui collecte, un **mobile** qui écoute. Tout ce qui
tenait le téléphone éveillé a migré ailleurs — c'est la raison d'être de cette version.
### Ajouté
- **Serveur** (`server/`, Python + FastAPI + SQLite). Il porte désormais la recherche dans les
catalogues, le guet des morceaux sur les radios, l'enregistrement des directs et la file de
téléchargement. Une interface web permet de le piloter depuis un navigateur : recherche, guets,
file et bibliothèque.
- **Recherche simultanée sur toutes les sources.** Les quatre catalogues sont interrogés en
parallèle et les résultats fusionnés, chacun étiqueté de son origine, les plus légers en tête.
Un catalogue en panne n'empêche pas les autres de répondre.
- **Balayage radio continu.** Un tour enchaîne sur le suivant au lieu d'attendre trois minutes :
un morceau guetté a été capté en moins de vingt-cinq secondes à la mesure. Une sonde ne lit que
le premier bloc de métadonnées puis referme, ce qui rend la permanence tenable là où garder
quarante flux ouverts coûterait plusieurs mégabits par seconde.
- **Synchronisation client/serveur.** Le mobile demande ce qui a changé depuis son curseur,
télécharge les fichiers prêts et se rendort. Toutes les six heures, en Wi-Fi non facturé et
batterie suffisante.
- **Réglages en sous-onglets** sur le mobile : Connexion, Synchronisation, Application.
- Cibles `make server-*` : environnement, lancement, service systemd, jeton, journal.
### Changé
- **L'application Android devient un client de synchronisation.** Recherche, catalogues, radio,
guets et enregistrement quittent le téléphone : vingt fichiers supprimés. Restent la
bibliothèque, la lecture, les playlists, le hors-ligne et les réglages.
- L'onglet Sources devient Réglages.
### Corrigé
- Le jeton du serveur était régénéré à chaque démarrage : il aurait fallu reconfigurer le mobile
après le moindre redémarrage. Il est désormais conservé dans `data/token`, en lecture seule pour
son propriétaire.
- Wikimedia Commons répondait 403 : sa politique refuse les agents génériques. L'agent annonce
maintenant le projet et son dépôt — 28 résultats là où il n'y en avait aucun. L'erreur était
masquée par la fusion des sources, et n'est apparue qu'en interrogeant la source seule.
- `NOXELY_DATA` pointait par défaut sur `/var/lib/noxely`, inaccessible au compte qui lance
`make server-run` : le défaut devient `./data`, et l'unité systemd crée elle-même son dossier
d'état. Un dossier inutilisable donne désormais une phrase, plus une trace Python.
- La lecture des métadonnées ICY n'itérait rien côté serveur : `iter_bytes` au lieu de
`aiter_bytes` sur un client asynchrone. Trente-six stations écoutées, zéro titre lu.
- Une coupure de l'annuaire Radio Browser faisait échouer tout le balayage : une seconde tentative
est faite, et l'erreur renvoyée est lisible.
### Migration
Le mobile seul ne sert plus à rien : il faut installer le serveur (`make server-venv`, puis
`make server-install`), relever son jeton (`make server-token`) et le saisir dans Réglages ▸
Connexion. Consultée depuis la machine du serveur, l'interface web se remplit seule.
## [1.7.0] — 2026-09-13
### Ajouté
- **Guet sur l'ensemble des stations.** On nomme un morceau ou un artiste, sans désigner de
station : Noxely balaie les quarante stations les plus écoutées, quatre à la fois, et déclenche
l'enregistrement dès qu'une le diffuse. Un écran dédié sépare ce qui est attendu de ce qui a été
capté, avec le nombre de balayages et l'heure du dernier.
- **Veille en arrière-plan.** Deux mécanismes, parce qu'un seul ne suffit pas : une tâche
périodique tourne en permanence au quart d'heure — le minimum qu'autorise Android — et une
veille active, à enclencher, balaie toutes les trois minutes, écran éteint compris. Un morceau
durant trois à quatre minutes, la tâche périodique en manque forcément ; l'écran le dit au lieu
de le taire. La veille s'arrête d'elle-même quand plus rien n'est guetté.
- **Pochettes automatiques.** Les titres sans image en reçoivent une, cherchée sur leur nom via la
recherche publique d'iTunes, sans clé. Le nom est nettoyé au préalable : un enregistrement radio
traîne celui de sa station, qui brouillerait la requête.
- **Bouton d'actualisation** de l'écran Hors ligne : relance le scan de l'appareil et complète les
pochettes manquantes, par lots bornés pour ne pas marteler le service.
- L'écran de lecture affiche la provenance du morceau — nom de la playlist s'il en vient —, sa
source, sa taille et sa date.
### Corrigé
- Un morceau guetté n'était pas enregistré : le champ de saisie était vidé avant que la coroutine
n'en lise la valeur. Elle est désormais capturée avant l'effacement.
## [1.6.0] — 2026-09-13
### Changé
- **L'application s'appelle désormais Noxely.** Le nom affiché, l'identifiant d'application
(`fr.tuxgyver.noxely`), le paquet Kotlin, le dépôt et le dossier d'enregistrements
(`Music/Noxely`) suivent tous.
⚠️ **Android considère le nouvel identifiant comme une application distincte.** Une
installation en 1.5.0 ne se met donc pas à jour toute seule : il faut la désinstaller, puis
installer Noxely. Les enregistrements déjà rangés dans `Music/Cachette` ne sont pas perdus —
ils sont dans le stockage partagé, restent lisibles par tout lecteur, et le scan continue de
les écarter pour ne pas les afficher en double.
- Nouvelle icône. Le visuel fourni est détouré de son fond, décliné aux cinq densités et posé
sur une couleur échantillonnée dans l'image : sans cela, le masque du système aurait découpé
une seconde fois un badge qui l'est déjà, en laissant du blanc sur les bords.
### Ajouté
- Page « À propos », accessible depuis l'écran Sources : ce que fait l'application, ce qu'elle
garantit, avec quoi elle est construite, ce qui a changé et qui en répond. La version est lue
du manifeste et le journal du CHANGELOG du dépôt, recopié à la compilation — l'un et l'autre
ne peuvent donc pas diverger de la réalité.
- La pastille de licence signale en rouge qu'aucune licence n'est déclarée dans le dépôt, plutôt
que d'en afficher une par défaut.
## [1.5.0] — 2026-09-13
### Ajouté
- **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 +215,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
+59 -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
@@ -86,6 +86,64 @@ logcat: ## Suit les journaux de l'application (Ctrl+C pour arrêter)
crash: ## Affiche la dernière trace de plantage éventuelle crash: ## Affiche la dernière trace de plantage éventuelle
@$(ADB) logcat -d -v brief | grep -A 30 -E "FATAL EXCEPTION" | tail -40 || echo "Aucun plantage enregistré" @$(ADB) logcat -d -v brief | grep -A 30 -E "FATAL EXCEPTION" | tail -40 || echo "Aucun plantage enregistré"
##@ 🖥️ Serveur
SERVER := server
SERVER_PY := $(SERVER)/venv/bin/python
SERVICE := noxely-server
UNIT_FILE := $(SERVER)/deploy/$(SERVICE).service
UNIT_DEST := /etc/systemd/system/$(SERVICE).service
PROJECT_DIR := $(abspath .)
SERVICE_USER ?= $(shell whoami)
.PHONY: server-venv
server-venv: ## Crée l'environnement Python du serveur et installe ses dépendances
python3 -m venv $(SERVER)/venv
$(SERVER)/venv/bin/pip install -q -r $(SERVER)/requirements.txt
@echo "→ $(SERVER_PY)"
.PHONY: server-token
server-token: ## Affiche le jeton du serveur (à recopier dans l'application)
@sed -n 's/^NOXELY_TOKEN=//p' $(SERVER)/.env 2>/dev/null | grep . \
|| cat $(SERVER)/data/token 2>/dev/null \
|| echo "Aucun jeton : lancez le serveur une fois, il en génère un."
.PHONY: server-run
server-run: ## Lance le serveur au premier plan (Ctrl+C pour arrêter)
@test -f $(SERVER)/.env || { echo "❌ $(SERVER)/.env absent — copiez $(SERVER)/.env.example"; exit 1; }
cd $(SERVER) && set -a && . ./.env && set +a && ./venv/bin/python -m noxely_server
.PHONY: server-install
server-install: ## Installe et active le service systemd (sudo requis)
@test -f $(SERVER)/.env || { echo "❌ $(SERVER)/.env absent — copiez $(SERVER)/.env.example"; exit 1; }
sed -e 's|__SERVICE_USER__|$(SERVICE_USER)|g' -e 's|@USER@|$(SERVICE_USER)|g' \
-e 's|@PROJECT@|$(PROJECT_DIR)|g' $(UNIT_FILE) | sudo tee $(UNIT_DEST) > /dev/null
sudo systemctl daemon-reload
sudo systemctl enable --now $(SERVICE)
@echo "✅ $(SERVICE) installé et démarré"
.PHONY: server-start
server-start: ## Démarre le service
sudo systemctl start $(SERVICE)
.PHONY: server-stop
server-stop: ## Arrête le service
sudo systemctl stop $(SERVICE)
.PHONY: server-status
server-status: ## État du service
systemctl status $(SERVICE) --no-pager
.PHONY: server-logs
server-logs: ## Suit le journal du service
journalctl -u $(SERVICE) -f
.PHONY: server-uninstall
server-uninstall: ## Désactive et retire le service (les données sont conservées)
sudo systemctl disable --now $(SERVICE) || true
sudo rm -f $(UNIT_DEST)
sudo systemctl daemon-reload
##@ 📤 Publication ##@ 📤 Publication
.PHONY: publish .PHONY: publish
+71 -28
View File
@@ -1,15 +1,16 @@
# 🎧 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. > Un serveur cherche, guette les radios et enregistre. Le téléphone ne fait que récupérer les
> fichiers prêts — et garde sa batterie.
[![Platform](https://img.shields.io/badge/Plateforme-Android%208.0%2B-3DDC84?logo=android&logoColor=white)](https://developer.android.com) [![Platform](https://img.shields.io/badge/Plateforme-Android%208.0%2B-3DDC84?logo=android&logoColor=white)](https://developer.android.com)
[![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-2.0.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.
@@ -23,42 +24,60 @@ sans la moindre connexion.
--- ---
## ✨ Fonctionnalités ## 🏗️ Architecture
```
SERVEUR (Python / FastAPI, sur votre machine)
├── cherche dans les catalogues libres
├── guette les morceaux sur les radios
├── enregistre les directs, titrage ICY compris
└── tient les fichiers prêts
│ HTTP + jeton
▼
MOBILE (Android / Compose)
└── demande ce qui est neuf, télécharge, lit
Wi-Fi non facturé · batterie suffisante · toutes les 6 h
```
Tout ce qui écoutait en continu a migré sur le serveur. Le téléphone dort entre deux
synchronisations : c'est là qu'est l'économie.
## ✨ Ce que fait le serveur
| | | | | |
|---|---| |---|---|
| 🎵 **Audio et vidéo** | Un seul lecteur, une seule file d'attente, la vidéo bascule sur une surface dédiée | | 🔎 **Recherche** | Internet Archive, Underground (netlabels + Free Music Archive), LibriVox, Wikimedia Commons |
| 📥 **Mise en cache explicite** | Rien n'est téléchargé sans un geste : vous décidez quoi garder | | 🧾 **Choix piste par piste** | Un album affiche ses pistes avec leur taille avant d'être mis en file |
| 🔍 **Parcours des sources** | On entre dans une source, on coche ce qu'on veut, on télécharge en lot | | 📡 **Guet multi-stations** | Nommez un morceau : les stations les plus écoutées sont balayées jusqu'à le trouver |
| 🎧 **Écoute avant téléchargement** | Chaque piste s'essaie en ligne d'un appui, sans rien mettre en cache | | 📻 **Enregistrement** | Capture du direct, nommée d'après le titre annoncé par la station |
| 🧾 **Choix piste par piste** | Un album ou un livre affiche ses pistes avec leur taille : on n'emporte que ce qu'on veut | | ⏳ **File de téléchargement** | Reprise après échec, état visible |
| 🌍 **Filtrage par langue** | Français, anglais, allemand, espagnol, italien, néerlandais | | 🖥️ **Interface web** | Tout se pilote depuis un navigateur, sans toucher au téléphone |
| 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre, lecture enchaînée |
| 🎼 **Catalogues libres de droits** | Recherche intégrée dans Internet Archive et LibriVox, sans compte ni clé d'API | ## 📱 Ce que fait le mobile
| 📻 **Podcasts RSS** | Un flux, ses épisodes, et le choix de ceux qu'on emporte |
| 🔗 **Liens directs** | N'importe quelle URL audio ou vidéo, y compris un serveur média auto-hébergé | | | |
| 📱 **Contenu de l'appareil** | Lecture de la bibliothèque MediaStore, déjà disponible hors connexion par nature | |---|---|
| 🎛️ **Lecture en arrière-plan** | Notification système, casque, Bluetooth, écran verrouillé | | 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante |
| ⏯️ **Contrôles complets** | Lecture, pause, arrêt, piste précédente et suivante, ±15 s | | 🎵 **Lecture** | Audio et vidéo, arrière-plan, casque, écran verrouillé, ±15 s, stop |
| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti | | 📚 **Bibliothèque** | Médias reçus du serveur et contenu déjà présent sur l'appareil |
| 🔁 **Reprise automatique** | Réseau coupé : mise en attente et reprise. Échec serveur : relance seule après 5 s, 15 s, 45 s | | 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre |
| 📻 **Radio** | Les stations les plus écoutées, en direct — et enregistrables d'un appui | | 🖼️ **Pochettes** | Cherchées automatiquement pour les titres qui n'en ont pas |
| 🏷️ **Titrage automatique** | L'enregistrement prend le nom du morceau annoncé par la station | | ℹ️ **À propos et mises à jour** | Version, garanties, journal, et suivi des releases du dépôt |
| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation |
--- ---
## 📦 Sources disponibles ## 📦 Sources interrogées par le serveur
| Source | État | Détail | | Source | État | Détail |
|---|:---:|---| |---|:---:|---|
| 📱 Appareil | ✅ | Musique et vidéos MediaStore | | 📱 Appareil | ✅ | Musique et vidéos déjà sur le téléphone (côté mobile) |
| 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo | | 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo |
| 📻 Podcast RSS | ✅ | Flux standard avec `<enclosure>` | | 📻 Podcast RSS | ✅ | Flux standard avec `<enclosure>` |
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons | | 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
| 📚 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 +87,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 +210,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 +269,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 2.0.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 -9
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>
@@ -59,11 +59,6 @@
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".radio.RecordingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -1,76 +0,0 @@
package fr.tuxgyver.cachette
import android.app.Application
import android.content.Context
import fr.tuxgyver.cachette.library.ImportedStore
import fr.tuxgyver.cachette.library.LibraryRepository
import fr.tuxgyver.cachette.library.LocalMediaScanner
import fr.tuxgyver.cachette.library.PlaylistStore
import fr.tuxgyver.cachette.offline.DownloadTracker
import fr.tuxgyver.cachette.offline.OfflineModule
import fr.tuxgyver.cachette.playback.PlayerController
import fr.tuxgyver.cachette.radio.RadioDirectory
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
import fr.tuxgyver.cachette.sources.Catalog
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.sources.CatalogItem
import fr.tuxgyver.cachette.sources.WikimediaCatalog
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
import fr.tuxgyver.cachette.sources.RssImporter
import fr.tuxgyver.cachette.sources.UrlImporter
import fr.tuxgyver.cachette.update.UpdateCheckWorker
import fr.tuxgyver.cachette.update.UpdateRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* Dépendances de l'application, assemblées à la main.
*
* Le gestionnaire de téléchargement Media3 exige le thread principal : tout est donc construit
* ici, au démarrage, et partagé tel quel avec les services.
*/
class AppContainer(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val offline = OfflineModule(context)
val downloads = DownloadTracker(context, offline.downloadManager, scope)
val imported = ImportedStore(context, scope)
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
val playlists = PlaylistStore(context, scope)
val radio = RadioDirectory()
val urlImporter = UrlImporter()
val rssImporter = RssImporter()
val catalogs: Map<CatalogId, Catalog> = listOf(
ArchiveOrgCatalog(),
LibriVoxCatalog(rssImporter),
WikimediaCatalog(),
).associateBy { it.id }
/**
* Résultat de catalogue en cours de consultation.
* Un identifiant de route ne suffirait pas à le reconstituer sans un appel réseau de plus.
*/
var openedCatalogItem: CatalogItem? = null
val player = PlayerController(context, scope) { id -> library.entry(id) }
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
}
class CachetteApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
UpdateCheckWorker.schedule(this)
}
}
@@ -1,81 +0,0 @@
package fr.tuxgyver.cachette.radio
import fr.tuxgyver.cachette.sources.openConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.net.URLEncoder
/**
* Annuaire des stations, via l'API communautaire Radio Browser (sans clé).
*
* Les flux HLS sont écartés : ils se lisent, mais ne s'enregistrent pas en copiant les octets,
* et proposer un bouton qui échouerait vaudrait moins que ne pas le proposer.
*/
class RadioDirectory {
private val json = Json { ignoreUnknownKeys = true }
/** Les stations les plus écoutées, éventuellement restreintes à un genre. */
suspend fun popular(genre: String? = null): Result<List<RadioStation>> =
query(buildString {
append("?limit=60&hidebroken=true&order=clickcount&reverse=true")
genre?.let { append("&tag=${URLEncoder.encode(it, "UTF-8")}") }
})
suspend fun search(name: String): Result<List<RadioStation>> =
query("?limit=60&hidebroken=true&order=clickcount&reverse=true" +
"&name=${URLEncoder.encode(name.trim(), "UTF-8")}")
private suspend fun query(suffix: String): Result<List<RadioStation>> = withContext(Dispatchers.IO) {
runCatching {
val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000)
val body = try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("L'annuaire a répondu ${connection.responseCode}")
}
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
(json.parseToJsonElement(body) as? JsonArray).orEmpty()
.mapNotNull { it as? JsonObject }
.filter { it.number("hls") == 0L }
.mapNotNull { station ->
val url = station.string("url_resolved")?.takeIf { it.startsWith("http") }
?: return@mapNotNull null
RadioStation(
id = station.string("stationuuid") ?: url,
name = station.string("name")?.trim() ?: "Station",
streamUrl = url,
codec = station.string("codec").orEmpty(),
bitrate = station.number("bitrate")?.toInt() ?: 0,
country = station.string("country").orEmpty(),
tags = station.string("tags").orEmpty(),
faviconUrl = station.string("favicon")?.takeIf { it.startsWith("http") },
)
}
.distinctBy { it.streamUrl }
}
}
private companion object {
const val BASE = "https://de1.api.radio-browser.info/json/stations/search"
/** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
fun JsonObject.string(key: String): String? =
(this[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
fun JsonObject.number(key: String): Long? =
(this[key] as? JsonPrimitive)?.content?.substringBefore('.')?.toLongOrNull()
}
}
/** Genres offerts en filtre rapide. */
val RADIO_GENRES = listOf("pop", "rock", "jazz", "classical", "electronic", "news", "chanson")
@@ -1,20 +0,0 @@
package fr.tuxgyver.cachette.radio
/** Une station de radio et son flux direct. */
data class RadioStation(
val id: String,
val name: String,
val streamUrl: String,
val codec: String,
val bitrate: Int,
val country: String,
val tags: String,
val faviconUrl: String?,
) {
val subtitle: String
get() = listOfNotNull(
country.takeIf { it.isNotBlank() },
tags.split(',').firstOrNull()?.trim()?.takeIf { it.isNotBlank() }?.replaceFirstChar(Char::uppercase),
bitrate.takeIf { it > 0 }?.let { "$it kbps" },
).joinToString(" · ")
}
@@ -1,43 +0,0 @@
package fr.tuxgyver.cachette.radio
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class RecordingStatus(
val station: RadioStation? = null,
val startedAt: Long = 0L,
val bytes: Long = 0L,
/** Titre annoncé par le flux, quand la station en diffuse un. */
val currentTitle: String? = null,
) {
val isRecording: Boolean get() = station != null
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
}
/**
* État de l'enregistrement en cours.
*
* Seul [RecordingService] y écrit ; l'interface ne fait que l'observer. Un singleton se justifie
* ici parce que l'enregistrement est unique par nature : on n'enregistre qu'une station à la fois.
*/
object Recorder {
private val _status = MutableStateFlow(RecordingStatus())
val status: StateFlow<RecordingStatus> = _status.asStateFlow()
internal fun started(station: RadioStation) {
_status.value = RecordingStatus(station, System.currentTimeMillis(), 0L)
}
internal fun progress(bytes: Long) {
_status.value = _status.value.copy(bytes = bytes)
}
internal fun title(title: String) {
_status.value = _status.value.copy(currentTitle = title)
}
internal fun stopped() {
_status.value = RecordingStatus()
}
}
@@ -1,335 +0,0 @@
package fr.tuxgyver.cachette.radio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import fr.tuxgyver.cachette.CachetteApp
import fr.tuxgyver.cachette.MainActivity
import fr.tuxgyver.cachette.R
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.sources.openConnection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Enregistre un flux radio en copiant ses octets dans un fichier.
*
* Un direct n'a pas de fin : `DownloadManager`, qui attend une taille et une complétion, ne peut
* pas s'en charger. L'enregistrement tourne donc en service de premier plan jusqu'à ce que
* l'utilisateur l'arrête, et le fichier obtenu rejoint la bibliothèque comme média local.
*/
class RecordingService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
private var target: File? = null
private var station: RadioStation? = null
private var firstTitle: String? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
finish()
return START_NOT_STICKY
}
ACTION_START -> {
val incoming = intent.toStation() ?: run {
stopSelf()
return START_NOT_STICKY
}
if (job?.isActive == true) return START_STICKY
begin(incoming)
}
}
return START_STICKY
}
private fun begin(incoming: RadioStation) {
station = incoming
firstTitle = null
Recorder.started(incoming)
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 file = File(directory, "${incoming.name.sanitized()}-$stamp.${incoming.extension()}")
target = file
job = scope.launch {
runCatching {
// « Icy-MetaData: 1 » demande à la station d'intercaler le titre en cours dans le
// flux. Ces octets ne sont pas de l'audio : ils doivent être retirés avant écriture,
// sinon le fichier obtenu est corrompu.
val connection = openConnection(incoming.streamUrl, readTimeoutMs = 30_000).apply {
setRequestProperty("Icy-MetaData", "1")
}
try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Flux indisponible (${connection.responseCode})")
}
val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0)
connection.inputStream.use { input ->
FileOutputStream(file).use { output ->
if (metaInterval > 0) {
copyWithTitles(input, output, metaInterval, incoming)
} else {
copyPlain(input, output, incoming)
}
output.flush()
}
}
} finally {
connection.disconnect()
}
}
// Fin du flux ou coupure réseau : ce qui a été capté reste valable.
finish()
}
}
/** Flux sans métadonnées : simple recopie. */
private suspend fun copyPlain(
input: java.io.InputStream,
output: FileOutputStream,
station: RadioStation,
) {
val buffer = ByteArray(32 * 1024)
var total = 0L
var lastNotified = 0L
while (currentCoroutineContext().isActive) {
val read = input.read(buffer)
if (read <= 0) break
output.write(buffer, 0, read)
total += read
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
lastNotified = total
Recorder.progress(total)
notifyProgress(station, total)
}
}
Recorder.progress(total)
}
/**
* Flux ICY : `interval` octets d'audio, puis un bloc de métadonnées à retirer.
* Le premier titre rencontré nomme l'enregistrement ; les suivants alimentent la notification.
*/
private suspend fun copyWithTitles(
input: java.io.InputStream,
output: FileOutputStream,
interval: Int,
station: RadioStation,
) {
val audio = ByteArray(interval)
var total = 0L
var lastNotified = 0L
while (currentCoroutineContext().isActive) {
if (!input.readFully(audio, interval)) break
output.write(audio)
total += interval
val lengthByte = input.read()
if (lengthByte < 0) break
val metaLength = lengthByte * 16
if (metaLength > 0) {
val meta = ByteArray(metaLength)
if (!input.readFully(meta, metaLength)) break
STREAM_TITLE
.find(String(meta, Charsets.UTF_8))
?.groupValues?.get(1)
?.trim()
?.takeIf { it.isNotBlank() }
?.let { title ->
if (firstTitle == null) firstTitle = title
Recorder.title(title)
notifyProgress(station, total)
}
}
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
lastNotified = total
Recorder.progress(total)
notifyProgress(station, total)
}
}
Recorder.progress(total)
}
/** Lit exactement [count] octets, ou renvoie false si le flux s'interrompt avant. */
private fun java.io.InputStream.readFully(target: ByteArray, count: Int): Boolean {
var filled = 0
while (filled < count) {
val read = read(target, filled, count - filled)
if (read < 0) return false
filled += read
}
return true
}
/** Clôt l'enregistrement et verse le fichier obtenu dans la bibliothèque. */
private fun finish() {
job?.cancel()
job = null
val file = target
val recorded = station
target = null
station = null
val captured = firstTitle
if (file != null && recorded != null && file.length() > MIN_USEFUL_BYTES) {
val elapsed = Recorder.status.value.elapsedMs
val container = (application as CachetteApp).container
val entry = MediaEntry(
id = "rec:${file.name}",
source = SourceKind.RECORDING,
kind = MediaKind.AUDIO,
// Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant.
title = captured
?: "${recorded.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}",
subtitle = if (captured != null) recorded.name else "Enregistrement radio",
uri = file.toURI().toString(),
artworkUri = recorded.faviconUrl,
durationMs = elapsed,
addedAt = System.currentTimeMillis(),
sizeBytes = file.length(),
)
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) }
} else {
file?.delete()
}
Recorder.stopped()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun notifyProgress(station: RadioStation, bytes: Long) {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, notification(station, bytes))
}
private fun notification(station: RadioStation, bytes: Long): Notification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Enregistrement radio", NotificationManager.IMPORTANCE_LOW)
)
}
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val stop = PendingIntent.getService(
this,
1,
Intent(this, RecordingService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline)
.setContentTitle("Enregistrement de ${station.name}")
.setContentText(
Recorder.status.value.currentTitle
?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…"
)
.setContentIntent(open)
.addAction(0, "Arrêter", stop)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun Intent.toStation(): RadioStation? {
val url = getStringExtra(EXTRA_URL) ?: return null
return RadioStation(
id = getStringExtra(EXTRA_ID).orEmpty(),
name = getStringExtra(EXTRA_NAME) ?: "Radio",
streamUrl = url,
codec = getStringExtra(EXTRA_CODEC).orEmpty(),
bitrate = getIntExtra(EXTRA_BITRATE, 0),
country = "",
tags = "",
faviconUrl = getStringExtra(EXTRA_FAVICON),
)
}
private fun RadioStation.extension(): String =
if (codec.equals("AAC", ignoreCase = true) || codec.startsWith("AAC", true)) "aac" else "mp3"
private fun String.sanitized(): String =
replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(' ', '_').take(40).ifBlank { "radio" }
companion object {
private const val ACTION_START = "fr.tuxgyver.cachette.RECORD_START"
private const val ACTION_STOP = "fr.tuxgyver.cachette.RECORD_STOP"
private const val EXTRA_ID = "id"
private const val EXTRA_NAME = "name"
private const val EXTRA_URL = "url"
private const val EXTRA_CODEC = "codec"
private const val EXTRA_BITRATE = "bitrate"
private const val EXTRA_FAVICON = "favicon"
private const val NOTIFICATION_ID = 4401
private const val CHANNEL_ID = "cachette_recording"
private const val NOTIFY_EVERY_BYTES = 256 * 1024L
private const val MIN_USEFUL_BYTES = 16 * 1024L
private val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
fun start(context: Context, station: RadioStation) {
val intent = Intent(context, RecordingService::class.java)
.setAction(ACTION_START)
.putExtra(EXTRA_ID, station.id)
.putExtra(EXTRA_NAME, station.name)
.putExtra(EXTRA_URL, station.streamUrl)
.putExtra(EXTRA_CODEC, station.codec)
.putExtra(EXTRA_BITRATE, station.bitrate)
.putExtra(EXTRA_FAVICON, station.faviconUrl)
context.startForegroundService(intent)
}
fun stop(context: Context) {
context.startService(
Intent(context, RecordingService::class.java).setAction(ACTION_STOP)
)
}
}
}
@@ -1,146 +0,0 @@
package fr.tuxgyver.cachette.sources
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import java.net.URLEncoder
/**
* Recherche dans archive.org.
*
* La recherche ne renvoie que des identifiants ; le fichier réellement lisible n'est déterminé
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
* trente éléments dont vingt-neuf seront ignorés.
*/
class ArchiveOrgCatalog : Catalog {
override val id = CatalogId.ARCHIVE
override val languages = COMMON_LANGUAGES
private val json = Json { ignoreUnknownKeys = true }
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
withContext(Dispatchers.IO) {
runCatching {
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
// dès la recherche plutôt que de les proposer puis d'échouer.
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
val q = URLEncoder.encode(
"($query) AND mediatype:(audio OR movies)$languageClause" +
" AND NOT access-restricted-item:true",
"UTF-8",
)
val url = "https://archive.org/advancedsearch.php?q=$q" +
"&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype&fl%5B%5D=item_size" +
"&rows=30&page=1&output=json"
val body = fetch(url)
val docs = json.parseToJsonElement(body).jsonObject["response"]
?.jsonObject?.get("docs") as? JsonArray
?: throw IllegalStateException("Réponse inattendue d'archive.org")
docs.mapNotNull { element ->
val doc = element as? JsonObject ?: return@mapNotNull null
val identifier = doc.text("identifier") ?: return@mapNotNull null
CatalogItem(
id = identifier,
title = doc.text("title") ?: identifier,
subtitle = doc.text("creator") ?: "Internet Archive",
artworkUri = "https://archive.org/services/img/$identifier",
hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio",
sizeBytes = doc.text("item_size")?.toLongOrNull(),
)
}
}
}
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
runCatching {
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
if (metadata["metadata"]?.jsonObject?.text("access-restricted-item") == "true") {
throw IllegalStateException("« ${item.title} » est en accès restreint sur archive.org")
}
val wantsVideo = item.hint == "Vidéo"
val extensions = if (wantsVideo) VIDEO_EXTENSIONS else AUDIO_EXTENSIONS
val files = (metadata["files"] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject }
val playable = files.filter { file ->
val name = file.text("name").orEmpty().lowercase()
extensions.any { name.endsWith(it) }
}
if (playable.isEmpty()) {
throw IllegalStateException(
"Aucun fichier ${if (wantsVideo) "vidéo" else "audio"} lisible dans « ${item.title} »"
)
}
// Un élément expose l'original et ses dérivés du même morceau. On regroupe par nom de
// base et on garde le plus léger de chaque groupe : autant de pistes que d'œuvres,
// pas autant que de formats.
playable.groupBy { it.text("name")!!.substringBeforeLast('.').lowercase() }
.values
.mapNotNull { variants ->
variants.minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE }
}
.sortedBy { it.text("name") }
.map { file ->
val name = file.text("name")!!
val seconds = file.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
MediaEntry(
id = stableId("ia:", "${item.id}/$name"),
source = SourceKind.URL,
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
title = file.text("title") ?: name.substringBeforeLast('.'),
subtitle = item.title,
uri = "https://archive.org/download/${item.id}/${encodePath(name)}",
artworkUri = item.artworkUri,
durationMs = seconds * 1000,
addedAt = System.currentTimeMillis(),
sizeBytes = file.text("size")?.toLongOrNull(),
)
}
}
}
/** Encode le nom de fichier sans toucher aux séparateurs de chemin. */
private fun encodePath(name: String): String =
name.split('/').joinToString("/") {
URLEncoder.encode(it, "UTF-8").replace("+", "%20")
}
private fun fetch(url: String): String {
val connection = openConnection(url)
return try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("archive.org a répondu ${connection.responseCode}")
}
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
}
private companion object {
val AUDIO_EXTENSIONS = listOf(".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav")
val VIDEO_EXTENSIONS = listOf(".mp4", ".m4v", ".webm", ".ogv")
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
}
}
/** archive.org renvoie tantôt une chaîne, tantôt un tableau : on prend la première valeur utile. */
internal fun JsonObject.text(key: String): String? = when (val value = this[key]) {
is JsonPrimitive -> value.content.takeIf { it.isNotBlank() }
is JsonArray -> value.firstOrNull()?.let { (it as? JsonPrimitive)?.content }?.takeIf { it.isNotBlank() }
else -> null
}
@@ -1,71 +0,0 @@
package fr.tuxgyver.cachette.sources
import fr.tuxgyver.cachette.model.MediaEntry
/** Un résultat de recherche : un album, un livre ou un morceau, pas encore un fichier. */
data class CatalogItem(
val id: String,
val title: String,
val subtitle: String,
val artworkUri: String? = null,
val hint: String = "",
/** Taille de l'ensemble, quand le catalogue l'annonce. */
val sizeBytes: Long? = null,
)
/** Langue proposée au filtrage. [code] est la valeur attendue par l'API du catalogue. */
data class CatalogLanguage(val code: String, val label: String)
/** Catalogue public interrogeable sans compte ni clé d'API. */
interface Catalog {
val id: CatalogId
/** Vide si le catalogue ne distingue pas les langues (la musique instrumentale, typiquement). */
val languages: List<CatalogLanguage> get() = emptyList()
suspend fun search(query: String, language: String? = null): Result<List<CatalogItem>>
/**
* Les pistes d'un résultat, avec leur taille.
*
* Un album ou un livre en compte plusieurs : c'est à l'utilisateur de choisir lesquelles il
* emporte, pas à l'application de décider pour lui.
*/
suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>>
}
enum class CatalogId(
val title: String,
val description: String,
val license: String,
val suggestion: String,
) {
ARCHIVE(
title = "Internet Archive",
description = "Musique, concerts et films versés au domaine public ou sous licence libre.",
license = "Domaine public / Creative Commons",
suggestion = "Grateful Dead",
),
LIBRIVOX(
title = "LibriVox",
description = "Livres audio lus par des bénévoles, tous dans le domaine public.",
license = "Domaine public",
suggestion = "Jules Verne",
),
WIKIMEDIA(
title = "Wikimedia Commons",
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
license = "Domaine public / Creative Commons",
suggestion = "Chopin",
),
}
/** Langues les plus fournies, communes à LibriVox et archive.org. */
val COMMON_LANGUAGES = listOf(
CatalogLanguage("French", "Français"),
CatalogLanguage("English", "Anglais"),
CatalogLanguage("German", "Allemand"),
CatalogLanguage("Spanish", "Espagnol"),
CatalogLanguage("Italian", "Italien"),
CatalogLanguage("Dutch", "Néerlandais"),
)
@@ -1,106 +0,0 @@
package fr.tuxgyver.cachette.sources
import fr.tuxgyver.cachette.model.MediaEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.net.URLEncoder
/**
* Recherche dans LibriVox.
*
* L'API a deux pièges, tous deux vérifiés contre le service réel :
* le champ `title` fait une correspondance **exacte** sauf s'il est préfixé par `^`
* (`title=Alice` ne donne rien, `title=^Alice` donne vingt-trois livres), et le champ `author`
* ne matche que le **nom de famille** (`author=Jules Verne` ne donne rien, `author=Verne` si).
* Un « aucun résultat » se présente enfin sous la forme d'un HTTP 404, pas d'une liste vide.
*
* D'où trois interrogations combinées et dédoublonnées, plutôt qu'une seule qui échouerait.
*
* Chaque livre publie un flux RSS : la résolution réutilise l'import de podcast plutôt que de
* redécrire un parcours de chapitres.
*/
class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
override val id = CatalogId.LIBRIVOX
override val languages = COMMON_LANGUAGES
private val json = Json { ignoreUnknownKeys = true }
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
withContext(Dispatchers.IO) {
runCatching {
// Le paramètre `language` de l'API est accepté puis ignoré : vérifié, `language=French`
// seul renvoie des livres anglais. Le tri se fait donc sur le champ `language` de
// chaque livre, et on demande davantage de résultats pour qu'il en reste après filtrage.
val limit = if (language == null) 25 else 100
val cleaned = query.trim()
val term = URLEncoder.encode(cleaned, "UTF-8")
val words = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() }
val criteria = buildList {
add("title=^$term")
add("author=$term")
// « Jules Verne » ne matche que sur « Verne » : on retente avec le nom de famille.
if (words.size > 1) add("author=${URLEncoder.encode(words.last(), "UTF-8")}")
}
// Les trois critères sont indépendants : les lancer en parallèle ramène l'attente
// à celle de la requête la plus lente, au lieu de leur somme.
coroutineScope {
criteria.map { async { books(it, limit, language) } }.awaitAll()
}.flatten().distinctBy { it.id }
}
}
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> =
rss.import(item.id).map { feed -> feed.episodes }
private fun books(criterion: String, limit: Int, language: String?): List<CatalogItem> {
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=$limit&$criterion"
// Le service met 14 à 16 secondes à répondre, quelle que soit la taille demandée :
// c'est sa latence propre, pas le volume. D'où une patience adaptée.
val connection = openConnection(url, readTimeoutMs = 45_000)
val body = try {
when (connection.responseCode) {
// LibriVox répond 404 quand le critère ne donne rien : ce n'est pas une panne.
404 -> return emptyList()
in 200..299 -> connection.inputStream.bufferedReader().readText()
else -> throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
}
} finally {
connection.disconnect()
}
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray ?: return emptyList()
return books.mapNotNull { element ->
val book = element as? JsonObject ?: return@mapNotNull null
if (language != null && !book.text("language").equals(language, ignoreCase = true)) {
return@mapNotNull null
}
val feed = book.text("url_rss") ?: return@mapNotNull null
CatalogItem(
id = feed,
title = book.text("title") ?: "Livre audio",
subtitle = book.authors(),
hint = book.text("totaltime")?.let { "Livre audio · $it" } ?: "Livre audio",
)
}
}
private fun JsonObject.authors(): String {
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
return authors.mapNotNull { it as? JsonObject }
.joinToString(", ") { author ->
listOfNotNull(author.text("first_name"), author.text("last_name")).joinToString(" ")
}
.ifBlank { "LibriVox" }
}
}
@@ -1,143 +0,0 @@
package fr.tuxgyver.cachette.sources
import android.util.Xml
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import java.text.SimpleDateFormat
import java.util.Locale
data class PodcastFeed(
val title: String,
val artworkUri: String?,
val episodes: List<MediaEntry>,
)
/** Importe un flux RSS de podcast : chaque `<enclosure>` devient une entrée téléchargeable. */
class RssImporter {
suspend fun import(feedUrl: String): Result<PodcastFeed> = withContext(Dispatchers.IO) {
val url = feedUrl.trim()
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://"))
}
runCatching {
val connection = openConnection(url)
try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
}
parse(connection.inputStream.bufferedReader().readText())
} finally {
connection.disconnect()
}
}
}
private fun parse(xml: String): PodcastFeed {
val parser = Xml.newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
setInput(xml.reader())
}
var feedTitle = ""
var feedImage: String? = null
val episodes = mutableListOf<MediaEntry>()
var item: Draft? = null
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
when (event) {
XmlPullParser.START_TAG -> when (parser.name) {
"item", "entry" -> item = Draft()
"title" -> {
val value = parser.textOrEmpty()
if (item != null) item.title = value else if (feedTitle.isEmpty()) feedTitle = value
}
"enclosure" -> if (item != null) {
item.url = parser.getAttributeValue(null, "url")
item.type = parser.getAttributeValue(null, "type").orEmpty()
}
"itunes:image" -> {
val href = parser.getAttributeValue(null, "href")
if (item != null) item.image = href else if (feedImage == null) feedImage = href
}
"itunes:duration" -> if (item != null) item.duration = parser.textOrEmpty()
"pubDate" -> if (item != null) item.pubDate = parser.textOrEmpty()
"url" -> if (item == null && feedImage == null) feedImage = parser.textOrEmpty()
}
XmlPullParser.END_TAG -> if (parser.name == "item" || parser.name == "entry") {
item?.toEntry(feedTitle, feedImage)?.let(episodes::add)
item = null
}
}
event = parser.next()
}
if (episodes.isEmpty()) {
throw IllegalStateException("Aucun épisode téléchargeable trouvé dans ce flux")
}
return PodcastFeed(
title = feedTitle.ifEmpty { "Podcast" },
artworkUri = feedImage,
episodes = episodes.take(MAX_EPISODES),
)
}
private class Draft {
var title: String = ""
var url: String? = null
var type: String = ""
var image: String? = null
var duration: String = ""
var pubDate: String = ""
fun toEntry(feedTitle: String, feedImage: String?): MediaEntry? {
val mediaUrl = url?.takeIf { it.isNotBlank() } ?: return null
return MediaEntry(
id = stableId("pod:", mediaUrl),
source = SourceKind.PODCAST,
kind = if (type.startsWith("video/")) MediaKind.VIDEO else MediaKind.AUDIO,
title = title.ifBlank { "Épisode" },
subtitle = feedTitle.ifBlank { "Podcast" },
uri = mediaUrl,
artworkUri = image ?: feedImage,
durationMs = parseDuration(duration),
addedAt = parseDate(pubDate),
)
}
}
private companion object {
const val MAX_EPISODES = 100
fun XmlPullParser.textOrEmpty(): String =
runCatching { nextText().trim() }.getOrDefault("")
/** `1:02:03`, `12:34` ou un nombre de secondes. */
fun parseDuration(raw: String): Long {
if (raw.isBlank()) return 0L
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
return when (parts.size) {
1 -> parts[0] * 1000
2 -> (parts[0] * 60 + parts[1]) * 1000
3 -> (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000
else -> 0L
}
}
fun parseDate(raw: String): Long {
if (raw.isBlank()) return System.currentTimeMillis()
val formats = listOf("EEE, dd MMM yyyy HH:mm:ss Z", "EEE, dd MMM yyyy HH:mm:ss zzz")
for (pattern in formats) {
runCatching { SimpleDateFormat(pattern, Locale.US).parse(raw)?.time }
.getOrNull()?.let { return it }
}
return System.currentTimeMillis()
}
}
}
@@ -1,65 +0,0 @@
package fr.tuxgyver.cachette.sources
import android.net.Uri
import androidx.core.net.toUri
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Importe un lien direct vers un fichier média (mp3, m4a, mp4, webm…). */
class UrlImporter {
suspend fun import(rawUrl: String): Result<MediaEntry> = withContext(Dispatchers.IO) {
val url = rawUrl.trim()
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://"))
}
runCatching {
val connection = openConnection(url, "HEAD")
val contentType = try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
}
connection.contentType.orEmpty()
} finally {
connection.disconnect()
}
val kind = kindOf(contentType, url)
?: throw IllegalStateException("Ce lien ne pointe pas vers un fichier audio ou vidéo")
val name = url.toUri().lastPathSegment
?.substringBeforeLast('.')
?.replace(Regex("[_-]+"), " ")
?.trim()
?.takeIf { it.isNotBlank() }
?: "Média importé"
MediaEntry(
id = stableId("url:", url),
source = SourceKind.URL,
kind = kind,
title = name,
subtitle = url.toUri().host ?: "Lien direct",
uri = url,
addedAt = System.currentTimeMillis(),
)
}
}
private fun kindOf(contentType: String, url: String): MediaKind? {
val mime = contentType.substringBefore(';').trim().lowercase()
return when {
mime.startsWith("video/") -> MediaKind.VIDEO
mime.startsWith("audio/") -> MediaKind.AUDIO
mime == "application/vnd.apple.mpegurl" || mime == "application/x-mpegurl" -> MediaKind.VIDEO
else -> when (url.substringAfterLast('.', "").lowercase().substringBefore('?')) {
"mp3", "m4a", "aac", "ogg", "opus", "flac", "wav" -> MediaKind.AUDIO
"mp4", "mkv", "webm", "m4v", "mov" -> MediaKind.VIDEO
else -> null
}
}
}
}
@@ -1,96 +0,0 @@
package fr.tuxgyver.cachette.sources
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import java.net.URLEncoder
/**
* Recherche de fichiers audio sur Wikimedia Commons.
*
* Retenu après avoir écarté deux candidats vérifiés : Openverse refuse désormais l'accès anonyme
* (401 sur huit requêtes sur huit) et ccMixter exige un en-tête `Referer` que Media3 ne permet pas
* de poser par téléchargement. Commons, lui, sert ses fichiers directement depuis
* upload.wikimedia.org, sans clé ni en-tête particulier, et annonce leur taille.
*
* Chaque résultat est déjà un fichier unique : la liste de pistes n'en contient donc qu'une.
*/
class WikimediaCatalog : Catalog {
override val id = CatalogId.WIKIMEDIA
private val json = Json { ignoreUnknownKeys = true }
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
withContext(Dispatchers.IO) {
runCatching {
val search = URLEncoder.encode("filetype:audio ${query.trim()}", "UTF-8")
val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json" +
"&generator=search&gsrsearch=$search&gsrnamespace=6&gsrlimit=30" +
"&prop=imageinfo&iiprop=url%7Csize%7Cmime%7Cextmetadata"
val connection = openConnection(url, readTimeoutMs = 30_000)
val body = try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Commons a répondu ${connection.responseCode}")
}
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
val pages = json.parseToJsonElement(body).jsonObject["query"]
?.jsonObject?.get("pages")?.jsonObject
?: return@runCatching emptyList()
pages.values.mapNotNull { element ->
val page = element as? JsonObject ?: return@mapNotNull null
val info = page["imageinfo"]?.jsonArray?.firstOrNull()?.jsonObject
?: return@mapNotNull null
val fileUrl = info.text("url") ?: return@mapNotNull null
if (!PLAYABLE.any { fileUrl.substringBefore('?').lowercase().endsWith(it) }) {
return@mapNotNull null
}
val licence = info["extmetadata"]?.jsonObject
?.get("LicenseShortName")?.jsonObject?.text("value")
CatalogItem(
id = fileUrl,
// Les titres Commons sont préfixés « File: » : inutile à l'écran.
title = page.text("title")?.removePrefix("File:")?.substringBeforeLast('.')
?: "Sans titre",
subtitle = "Wikimedia Commons",
hint = licence ?: "Domaine public / CC",
sizeBytes = info.text("size")?.toLongOrNull(),
)
}
}
}
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = Result.success(
listOf(
MediaEntry(
id = stableId("wm:", item.id),
source = SourceKind.URL,
kind = MediaKind.AUDIO,
title = item.title,
subtitle = item.subtitle,
uri = item.id,
addedAt = System.currentTimeMillis(),
sizeBytes = item.sizeBytes,
)
)
)
private companion object {
/** Formats que Media3 sait lire parmi ceux que Commons héberge. */
val PLAYABLE = listOf(".ogg", ".oga", ".opus", ".flac", ".wav", ".mp3", ".m4a")
}
}
@@ -1,233 +0,0 @@
package fr.tuxgyver.cachette.ui.browse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.sources.CatalogItem
import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatBytes
import kotlinx.coroutines.launch
/**
* Recherche dans un catalogue libre de droits.
*
* Un résultat n'est pas un fichier mais un album, un livre ou un morceau : l'ouvrir mène à la
* liste de ses pistes, où l'on écoute et où l'on choisit ce qu'on emporte.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CatalogBrowseScreen(
container: AppContainer,
catalogId: CatalogId,
onBack: () -> Unit,
onOpenItem: (CatalogItem) -> Unit,
contentPadding: PaddingValues,
) {
val catalog = container.catalogs.getValue(catalogId)
val scope = rememberCoroutineScope()
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
var language by rememberSaveable { mutableStateOf<String?>(null) }
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
fun runSearch() {
if (query.isBlank()) return
searching = true
error = null
scope.launch {
catalog.search(query, language).fold(
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
onFailure = { error = it.message ?: "Recherche impossible" },
)
searching = false
}
}
LaunchedEffect(catalogId, language) { runSearch() }
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(catalogId.title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
)
Text(
text = catalogId.license,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = 16.dp),
)
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Rechercher dans ${catalogId.title}") },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
singleLine = true,
shape = MaterialTheme.shapes.large,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
if (catalog.languages.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
FilterChip(
selected = language == null,
onClick = { language = null },
label = { Text("Toutes langues") },
)
catalog.languages.forEach { option ->
FilterChip(
selected = language == option.code,
onClick = { language = option.code },
label = { Text(option.label) },
)
}
}
Spacer(Modifier.size(8.dp))
}
when {
searching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
error != null -> Box(
Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = error!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
else -> LazyColumn(
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(results, key = { it.id }) { item ->
CatalogRow(item = item, onOpen = { onOpenItem(item) })
}
}
}
}
}
@Composable
private fun CatalogRow(item: CatalogItem, onOpen: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp),
) {
Artwork(
uri = item.artworkUri,
title = item.title,
kind = if (item.hint.contains("Vidéo")) MediaKind.VIDEO else MediaKind.AUDIO,
shape = RoundedCornerShape(14.dp),
modifier = Modifier.size(56.dp),
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = listOfNotNull(
item.subtitle.takeIf { it.isNotBlank() },
item.hint.takeIf { it.isNotBlank() },
item.sizeBytes?.let { "~${formatBytes(it)}" },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = "Voir les pistes",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -1,255 +0,0 @@
package fr.tuxgyver.cachette.ui.browse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.PlayCircleOutline
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.ui.components.formatBytes
import fr.tuxgyver.cachette.ui.components.formatDuration
import kotlinx.coroutines.launch
/**
* Les pistes d'un résultat de catalogue.
*
* Un album ou un livre en compte souvent des dizaines : chacune s'écoute d'abord en ligne, et
* n'est mise en cache que si on la coche. La taille de la sélection est annoncée avant l'envoi.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CatalogItemScreen(
container: AppContainer,
catalogId: CatalogId,
currentMediaId: String?,
onBack: () -> Unit,
onMessage: (String) -> Unit,
contentPadding: PaddingValues,
) {
val item = container.openedCatalogItem
val scope = rememberCoroutineScope()
var tracks by remember { mutableStateOf<List<MediaEntry>>(emptyList()) }
var selection by remember { mutableStateOf(emptySet<String>()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var adding by remember { mutableStateOf(false) }
LaunchedEffect(item?.id) {
val opened = item ?: run {
onBack()
return@LaunchedEffect
}
loading = true
container.catalogs.getValue(catalogId).tracks(opened).fold(
onSuccess = { tracks = it; error = if (it.isEmpty()) "Aucune piste lisible" else null },
onFailure = { error = it.message ?: "Contenu illisible" },
)
loading = false
}
val selectedSize = remember(selection, tracks) {
tracks.filter { it.id in selection }.sumOf { it.sizeBytes ?: 0L }
}
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = {
Text(
text = item?.title ?: "Pistes",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
actions = {
if (tracks.isNotEmpty()) {
TextButton(onClick = {
selection = if (selection.size == tracks.size) emptySet()
else tracks.mapTo(mutableSetOf()) { it.id }
}) {
Text(if (selection.size == tracks.size) "Aucune" else "Toutes")
}
}
},
)
if (tracks.isNotEmpty()) {
val total = tracks.sumOf { it.sizeBytes ?: 0L }
Text(
text = buildString {
append("${tracks.size} piste(s)")
if (total > 0) append(" · ${formatBytes(total)} au total")
append(" · appuyez pour écouter en ligne")
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
error != null -> Box(
Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = error!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
else -> LazyColumn(
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(tracks, key = { it.id }) { track ->
TrackRow(
track = track,
playing = track.id == currentMediaId,
selected = track.id in selection,
onPreview = {
// Un aperçu ne rejoint pas la bibliothèque : on le confie au
// registre transitoire pour que la lecture sache le résoudre.
container.library.preview(tracks)
container.player.play(tracks, tracks.indexOf(track))
},
onToggle = {
selection = if (track.id in selection) selection - track.id
else selection + track.id
},
)
}
item { Spacer(Modifier.size(88.dp)) }
}
}
}
SelectionBar(
count = selection.size,
actionLabel = if (selectedSize > 0) "Garder ${formatBytes(selectedSize)}" else "Garder",
busy = adding,
onClear = { selection = emptySet() },
onConfirm = {
adding = true
scope.launch {
val chosen = tracks.filter { it.id in selection }
val added = container.imported.add(chosen)
chosen.forEach(container.downloads::download)
adding = false
selection = emptySet()
onMessage(
if (added == 0) "Ces pistes sont déjà dans la bibliothèque"
else "$added piste(s) ajoutées, téléchargement lancé"
)
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(contentPadding),
)
}
}
@Composable
private fun TrackRow(
track: MediaEntry,
playing: Boolean,
selected: Boolean,
onPreview: () -> Unit,
onToggle: () -> Unit,
) {
Surface(
color = when {
playing -> MaterialTheme.colorScheme.primaryContainer
selected -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surface
},
shape = MaterialTheme.shapes.small,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onPreview),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Icon(
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
contentDescription = if (playing) "En lecture" else "Écouter en ligne",
tint = if (playing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = listOfNotNull(
track.durationMs.takeIf { it > 0 }?.let(::formatDuration),
track.sizeBytes?.takeIf { it > 0 }?.let(::formatBytes),
).joinToString(" · ").ifBlank { "Taille inconnue" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Checkbox(checked = selected, onCheckedChange = { onToggle() })
}
}
}
@@ -1,78 +0,0 @@
package fr.tuxgyver.cachette.ui.browse
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Barre d'action des écrans de sélection : combien d'éléments, et que faire de la sélection. */
@Composable
fun SelectionBar(
count: Int,
actionLabel: String,
busy: Boolean,
onClear: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = count > 0,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = modifier,
) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.large,
tonalElevation = 3.dp,
shadowElevation = 8.dp,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Text(
text = "$count sélectionné(s)",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onClear, enabled = !busy) { Text("Effacer") }
Button(onClick = onConfirm, enabled = !busy) {
if (busy) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp),
)
} else {
Icon(Icons.Rounded.Download, contentDescription = null, modifier = Modifier.size(18.dp))
}
Spacer(Modifier.size(8.dp))
Text(actionLabel)
}
}
}
}
}
@@ -1,178 +0,0 @@
package fr.tuxgyver.cachette.ui.browse
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.ui.components.MediaRow
/**
* Parcours d'une source déjà présente dans la bibliothèque : on y choisit, un par un ou en lot,
* ce qui doit être gardé sur l'appareil.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SourceBrowseScreen(
container: AppContainer,
source: SourceKind,
currentMediaId: String?,
onBack: () -> Unit,
onPlay: (List<MediaEntry>, Int) -> Unit,
contentPadding: PaddingValues,
) {
val entries by container.library.entries.collectAsStateWithLifecycle()
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
val ofSource = remember(entries, source) { entries.filter { it.source == source } }
val collections = remember(ofSource) { ofSource.map { it.subtitle }.distinct().sorted() }
var collection by rememberSaveable { mutableStateOf<String?>(null) }
var selection by remember { mutableStateOf(emptySet<String>()) }
val visible = remember(ofSource, collection) {
if (collection == null) ofSource else ofSource.filter { it.subtitle == collection }
}
val selectable = remember(visible, offlineStates) {
visible.filter { it.isRemote && offlineStates[it.id]?.isComplete != true }
}
// La barre de titre occupe déjà l'espace haut du Scaffold : la liste ne reprend que le bas.
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(source.label) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
actions = {
if (selectable.isNotEmpty()) {
TextButton(onClick = {
selection = if (selection.size == selectable.size) emptySet()
else selectable.mapTo(mutableSetOf()) { it.id }
}) {
Text(if (selection.size == selectable.size) "Aucun" else "Tout")
}
}
},
)
if (collections.size > 1) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
FilterChip(
selected = collection == null,
onClick = { collection = null },
label = { Text("Tout") },
)
collections.forEach { name ->
FilterChip(
selected = collection == name,
onClick = { collection = name },
label = { Text(name) },
)
}
}
}
if (visible.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
) {
Text(
text = "Aucun contenu pour cette source. Ajoutez-en depuis l'écran Sources.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
} else {
LazyColumn(
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(visible, key = { it.id }) { entry ->
val canSelect = entry.isRemote && offlineStates[entry.id]?.isComplete != true
MediaRow(
entry = entry,
offline = offlineStates[entry.id],
isCurrent = entry.id == currentMediaId,
selected = if (canSelect) entry.id in selection else null,
onClick = {
if (canSelect) {
selection = if (entry.id in selection) selection - entry.id
else selection + entry.id
} else {
onPlay(visible, visible.indexOf(entry))
}
},
onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) },
)
}
item { Spacer(Modifier.size(88.dp)) }
}
}
}
SelectionBar(
count = selection.size,
actionLabel = "Télécharger",
busy = false,
onClear = { selection = emptySet() },
onConfirm = {
visible.filter { it.id in selection }.forEach(container.downloads::download)
selection = emptySet()
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(contentPadding),
)
}
}
@@ -1,353 +0,0 @@
package fr.tuxgyver.cachette.ui.radio
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.FiberManualRecord
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.PlayCircleOutline
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.radio.RADIO_GENRES
import fr.tuxgyver.cachette.radio.RadioStation
import fr.tuxgyver.cachette.radio.Recorder
import fr.tuxgyver.cachette.radio.RecordingService
import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatBytes
import fr.tuxgyver.cachette.ui.components.formatDuration
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Les stations les plus écoutées, à écouter en direct ou à enregistrer.
*
* L'enregistrement produit un fichier local qui rejoint la bibliothèque : il n'a donc rien à
* télécharger ensuite, il est déjà hors connexion.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RadioScreen(
container: AppContainer,
currentMediaId: String?,
onBack: () -> Unit,
onMessage: (String) -> Unit,
contentPadding: PaddingValues,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val status by Recorder.status.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var genre by rememberSaveable { mutableStateOf<String?>(null) }
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
fun load() {
loading = true
error = null
scope.launch {
val outcome = if (query.isBlank()) container.radio.popular(genre)
else container.radio.search(query)
outcome.fold(
onSuccess = { stations = it; if (it.isEmpty()) error = "Aucune station" },
onFailure = { error = it.message ?: "Annuaire indisponible" },
)
loading = false
}
}
LaunchedEffect(genre) { load() }
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Radio") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
)
if (status.isRecording) {
// La progression n'est publiée que tous les 256 Ko : le chronomètre se rafraîchit
// donc de lui-même, sinon il semblerait figé entre deux paliers.
var tick by remember { mutableStateOf(0L) }
LaunchedEffect(status.startedAt) {
while (true) {
tick = System.currentTimeMillis()
delay(1_000)
}
}
RecordingBanner(
station = status.station!!,
title = status.currentTitle,
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
bytes = status.bytes,
onStop = {
RecordingService.stop(context)
onMessage("Enregistrement versé dans la bibliothèque")
},
)
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Rechercher une station") },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
singleLine = true,
shape = MaterialTheme.shapes.large,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { load() }),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
FilterChip(
selected = genre == null,
onClick = { genre = null; query = "" },
label = { Text("Les plus écoutées") },
)
RADIO_GENRES.forEach { option ->
FilterChip(
selected = genre == option,
onClick = { genre = option; query = "" },
label = { Text(option.replaceFirstChar(Char::uppercase)) },
)
}
}
Spacer(Modifier.size(8.dp))
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
error != null -> Box(
Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = error!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
else -> LazyColumn(
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(stations, key = { it.id }) { station ->
StationRow(
station = station,
playing = station.asEntry().id == currentMediaId,
recording = status.station?.id == station.id,
onListen = {
val entry = station.asEntry()
container.library.preview(listOf(entry))
container.player.play(listOf(entry), 0)
},
onRecord = {
if (status.isRecording) {
onMessage("Un enregistrement est déjà en cours")
} else {
RecordingService.start(context, station)
onMessage("Enregistrement de ${station.name} démarré")
}
},
)
}
item { Spacer(Modifier.size(24.dp)) }
}
}
}
}
/** Une station écoutée en direct est un média éphémère : pas de durée, pas de mise en cache. */
private fun RadioStation.asEntry() = MediaEntry(
id = "radio:$id",
source = SourceKind.URL,
kind = MediaKind.AUDIO,
title = name,
subtitle = subtitle.ifBlank { "Radio en direct" },
uri = streamUrl,
artworkUri = faviconUrl,
)
@Composable
private fun RecordingBanner(
station: RadioStation,
title: String?,
elapsedMs: Long,
bytes: Long,
onStop: () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp),
) {
Icon(
Icons.Rounded.FiberManualRecord,
contentDescription = null,
tint = Color(0xFFD32F2F),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = title ?: station.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = listOfNotNull(
title?.let { station.name },
formatDuration(elapsedMs),
formatBytes(bytes),
).joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Button(onClick = onStop) {
Icon(Icons.Rounded.Stop, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(6.dp))
Text("Arrêter")
}
}
}
}
@Composable
private fun StationRow(
station: RadioStation,
playing: Boolean,
recording: Boolean,
onListen: () -> Unit,
onRecord: () -> Unit,
) {
Surface(
color = if (playing) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onListen),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp),
) {
Artwork(
uri = station.faviconUrl,
title = station.name,
kind = MediaKind.AUDIO,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = station.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = station.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
contentDescription = if (playing) "En écoute" else "Écouter en direct",
tint = if (playing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(onClick = onRecord) {
Icon(
Icons.Rounded.FiberManualRecord,
contentDescription = "Enregistrer cette station",
tint = if (recording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -1,415 +0,0 @@
package fr.tuxgyver.cachette.ui.sources
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.MenuBook
import androidx.compose.material.icons.rounded.LibraryMusic
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Radio
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Podcasts
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Smartphone
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.sources.CatalogId
import kotlinx.coroutines.launch
@Composable
fun SourcesScreen(
container: AppContainer,
contentPadding: PaddingValues,
onMessage: (String) -> Unit,
onBrowseSource: (SourceKind) -> Unit,
onBrowseCatalog: (CatalogId) -> Unit,
onBrowseRadio: () -> Unit,
) {
val entries by container.library.entries.collectAsStateWithLifecycle()
var sheet by remember { mutableStateOf<ImportKind?>(null) }
val deviceCount = entries.count { it.source == SourceKind.LOCAL }
val linkCount = entries.count { it.source == SourceKind.URL }
val podcastCount = entries.count { it.source == SourceKind.PODCAST }
LazyColumn(
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
) {
item {
Column(Modifier.padding(top = 12.dp)) {
Text("Sources", style = MaterialTheme.typography.displaySmall)
Text(
"D'où vient le contenu que vous gardez hors connexion.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item {
SourceCard(
icon = Icons.Rounded.Smartphone,
title = "Appareil",
status = "$deviceCount éléments",
description = "Musique et vidéos déjà présentes sur le téléphone. Toujours disponibles.",
actionLabel = "Actualiser",
actionIcon = Icons.Rounded.Refresh,
onAction = { container.library.refreshLocal() },
onOpen = { onBrowseSource(SourceKind.LOCAL) },
)
}
item {
SourceCard(
icon = Icons.Rounded.Link,
title = "Lien direct",
status = "$linkCount éléments",
description = "Une URL vers un fichier audio ou vidéo. Le fichier est ensuite copié dans la cachette.",
actionLabel = "Ajouter un lien",
onAction = { sheet = ImportKind.URL },
onOpen = { onBrowseSource(SourceKind.URL) },
)
}
item {
SourceCard(
icon = Icons.Rounded.Podcasts,
title = "Podcast",
status = "$podcastCount épisodes",
description = "Un flux RSS. Les épisodes deviennent téléchargeables un par un.",
actionLabel = "Ajouter un flux",
onAction = { sheet = ImportKind.RSS },
onOpen = { onBrowseSource(SourceKind.PODCAST) },
)
}
item {
val recordings = entries.count { it.source == SourceKind.RECORDING }
SourceCard(
icon = Icons.Rounded.Radio,
title = "Radio",
status = if (recordings > 0) "$recordings enregistrement(s)" else "Direct et enregistrement",
description = "Les stations les plus écoutées. À suivre en direct, ou à enregistrer pour l'écouter plus tard.",
actionLabel = "Ouvrir la radio",
onAction = onBrowseRadio,
onOpen = onBrowseRadio,
)
}
item {
Spacer(Modifier.size(8.dp))
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
Text(
"Rechercher, choisir, emporter. Aucun compte requis.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(CatalogId.entries) { catalog ->
SourceCard(
icon = when (catalog) {
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
else -> Icons.Rounded.Public
},
title = catalog.title,
status = catalog.license,
description = catalog.description,
actionLabel = "Parcourir",
actionIcon = Icons.Rounded.Search,
onAction = { onBrowseCatalog(catalog) },
onOpen = { onBrowseCatalog(catalog) },
)
}
item {
Spacer(Modifier.size(8.dp))
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
}
item {
Spacer(Modifier.size(8.dp))
UpdateCard(container)
}
unavailableSourceCards(
listOf(
UnavailableSource(
"Spotify",
"Le SDK Spotify ne donne pas accès au flux audio : la lecture hors connexion n'existe que dans l'application Spotify. Un podcast diffusé aussi en RSS public peut en revanche être ajouté ci-dessus.",
),
UnavailableSource(
"YouTube",
"Les conditions d'utilisation interdisent d'extraire ou de stocker les flux. Le hors-ligne passe par YouTube Premium, dans leur application.",
),
UnavailableSource(
"Google Drive / Photos",
"Techniquement possible pour vos propres fichiers, mais cela demande un identifiant OAuth Google propre à votre compte développeur, qui n'est pas fourni ici.",
),
)
)
}
val current = sheet
if (current != null) {
ImportSheet(
kind = current,
container = container,
onDismiss = { sheet = null },
onMessage = onMessage,
)
}
}
private data class UnavailableSource(val title: String, val explanation: String)
private fun androidx.compose.foundation.lazy.LazyListScope.unavailableSourceCards(sources: List<UnavailableSource>) {
sources.forEach { source ->
item {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(16.dp)) {
Icon(
imageVector = Icons.Rounded.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(12.dp))
Column {
Text(source.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.size(4.dp))
Text(
source.explanation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun SourceCard(
icon: ImageVector,
title: String,
status: String,
description: String,
actionLabel: String,
actionIcon: ImageVector? = null,
onAction: () -> Unit,
onOpen: () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.large,
tonalElevation = 2.dp,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen),
) {
Column(Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(10.dp),
)
}
Spacer(Modifier.size(14.dp))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
status,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
)
}
Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = "Parcourir",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.size(10.dp))
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(12.dp))
Button(onClick = onAction) {
if (actionIcon != null) {
Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
}
Text(actionLabel)
}
}
}
}
internal enum class ImportKind(val title: String, val hint: String) {
URL("Ajouter un lien direct", "https://exemple.org/morceau.mp3"),
RSS("Ajouter un flux podcast", "https://exemple.org/podcast.xml"),
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ImportSheet(
kind: ImportKind,
container: AppContainer,
onDismiss: () -> Unit,
onMessage: (String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()
var url by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var mode by remember { mutableStateOf(kind) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
Modifier
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
.navigationBarsPadding(),
) {
Text(mode.title, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.size(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ImportKind.entries.forEach { option ->
FilterChip(
selected = mode == option,
onClick = { mode = option; error = null },
label = { Text(if (option == ImportKind.URL) "Lien direct" else "Flux podcast") },
)
}
}
Spacer(Modifier.size(16.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it; error = null },
placeholder = { Text(mode.hint) },
singleLine = true,
isError = error != null,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
)
error?.let {
Spacer(Modifier.size(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.size(20.dp))
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(onClick = onDismiss, enabled = !busy) { Text("Annuler") }
Spacer(Modifier.size(8.dp))
Button(
enabled = !busy && url.isNotBlank(),
onClick = {
busy = true
error = null
scope.launch {
val outcome = runImport(container, mode, url)
busy = false
outcome.fold(
onSuccess = { message ->
onMessage(message)
onDismiss()
},
onFailure = { error = it.message ?: "Import impossible" },
)
}
},
) {
if (busy) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.size(8.dp))
}
Text("Importer")
}
}
}
}
}
private suspend fun runImport(
container: AppContainer,
kind: ImportKind,
url: String,
): Result<String> = when (kind) {
ImportKind.URL -> container.urlImporter.import(url).mapCatching { entry ->
val added = container.imported.add(listOf(entry))
if (added == 0) "« ${entry.title} » est déjà dans la bibliothèque"
else "« ${entry.title} » ajouté"
}
ImportKind.RSS -> container.rssImporter.import(url).mapCatching { feed ->
val added = container.imported.add(feed.episodes)
if (added == 0) "${feed.title} : aucun nouvel épisode"
else "${feed.title} : $added épisode(s) ajouté(s)"
}
}
@@ -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()
} }
@@ -0,0 +1,63 @@
package fr.tuxgyver.noxely
import android.app.Application
import android.content.Context
import fr.tuxgyver.noxely.library.ArtworkFinder
import fr.tuxgyver.noxely.library.ImportedStore
import fr.tuxgyver.noxely.library.LibraryRepository
import fr.tuxgyver.noxely.library.LocalMediaScanner
import fr.tuxgyver.noxely.library.PlaylistStore
import fr.tuxgyver.noxely.offline.DownloadTracker
import fr.tuxgyver.noxely.offline.OfflineModule
import fr.tuxgyver.noxely.playback.PlayerController
import fr.tuxgyver.noxely.sync.ServerSettings
import fr.tuxgyver.noxely.sync.SyncClient
import fr.tuxgyver.noxely.sync.SyncWorker
import fr.tuxgyver.noxely.update.UpdateCheckWorker
import fr.tuxgyver.noxely.update.UpdateRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* Dépendances de l'application, assemblées à la main.
*
* Le gestionnaire de téléchargement Media3 exige le thread principal : tout est donc construit
* ici, au démarrage, et partagé tel quel avec les services.
*/
class AppContainer(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val offline = OfflineModule(context)
val downloads = DownloadTracker(context, offline.downloadManager, scope)
val imported = ImportedStore(context, scope)
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
val playlists = PlaylistStore(context, scope)
val artwork = ArtworkFinder()
val serverSettings = ServerSettings(context, scope)
val sync = SyncClient(context, serverSettings, imported)
val player = PlayerController(context, scope) { id -> library.entry(id) }
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
}
class NoxelyApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
UpdateCheckWorker.schedule(this)
SyncWorker.schedule(this)
}
}
@@ -0,0 +1,61 @@
package fr.tuxgyver.noxely.library
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.URLEncoder
/**
* Cherche une pochette pour un titre, via la recherche publique d'iTunes (sans clé).
*
* Un enregistrement radio ou un fichier importé n'a pas d'image : celle-ci est déduite du titre
* et de l'interprète. Rien n'est téléchargé ici — seule l'adresse est retenue, et l'affichage
* s'en charge, ce qui évite de stocker des images pour des médias qu'on ne regardera pas.
*/
class ArtworkFinder {
private val json = Json { ignoreUnknownKeys = true }
suspend fun find(entry: MediaEntry): String? = withContext(Dispatchers.IO) {
val term = buildString {
append(entry.title.cleaned())
val artist = entry.subtitle.cleaned()
if (artist.isNotBlank() && !entry.title.contains(artist, ignoreCase = true)) {
append(' ').append(artist)
}
}.trim().take(120)
if (term.length < 3) return@withContext null
runCatching {
val url = "https://itunes.apple.com/search" +
"?term=${URLEncoder.encode(term, "UTF-8")}&entity=song&limit=1"
val connection = openConnection(url, readTimeoutMs = 15_000)
val body = try {
if (connection.responseCode !in 200..299) return@runCatching null
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
val first = (json.parseToJsonElement(body).jsonObject["results"] as? JsonArray)
?.firstOrNull() as? JsonObject ?: return@runCatching null
// La vignette est servie en 100 px ; l'adresse accepte une taille supérieure.
first["artworkUrl100"]?.jsonPrimitive?.content?.replace("100x100", "600x600")
}.getOrNull()
}
/** Les enregistrements radio traînent le nom de la station : il brouille la recherche. */
private fun String.cleaned(): String = this
.substringBefore(" - Classic Vinyl on ")
.replace(Regex("\\s+on\\s+\\S+\\.(com|fr|net|org)", RegexOption.IGNORE_CASE), "")
.replace(Regex("^\\d{1,3}\\s*[-.]\\s*"), "")
.replace(Regex("\\.(mp3|aac|ogg|m4a|flac)$", RegexOption.IGNORE_CASE), "")
.trim()
}
@@ -1,7 +1,7 @@
package fr.tuxgyver.cachette.library package fr.tuxgyver.noxely.library
import android.content.Context import 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
@@ -45,6 +45,15 @@ class ImportedStore(context: Context, private val scope: CoroutineScope) {
fresh.size fresh.size
} }
/** Renseigne la pochette trouvée pour une entrée, sans toucher au reste. */
suspend fun setArtwork(id: String, artworkUri: String) = mutex.withLock {
val merged = _entries.value.map { if (it.id == id) it.copy(artworkUri = artworkUri) else it }
if (merged != _entries.value) {
_entries.value = merged
write(merged)
}
}
suspend fun remove(id: String) = mutex.withLock { suspend fun remove(id: String) = mutex.withLock {
val merged = _entries.value.filterNot { it.id == id } val merged = _entries.value.filterNot { it.id == id }
if (merged.size != _entries.value.size) { if (merged.size != _entries.value.size) {
@@ -1,6 +1,6 @@
package fr.tuxgyver.cachette.library package fr.tuxgyver.noxely.library
import fr.tuxgyver.cachette.model.MediaEntry import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.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
@@ -10,6 +10,7 @@ enum class SourceKind(val label: String) {
URL("Lien direct"), URL("Lien direct"),
PODCAST("Podcast"), PODCAST("Podcast"),
RECORDING("Enregistrement"), RECORDING("Enregistrement"),
SERVER("Serveur"),
} }
/** /**
@@ -33,5 +34,5 @@ data class MediaEntry(
val sizeBytes: Long? = null, val sizeBytes: Long? = null,
) { ) {
/** Local ou enregistré : le fichier est déjà sur l'appareil, il n'y a rien à télécharger. */ /** Local ou enregistré : le fichier est déjà sur l'appareil, il n'y a rien à télécharger. */
val isRemote: Boolean get() = source != SourceKind.LOCAL && source != SourceKind.RECORDING val isRemote: Boolean get() = source == SourceKind.URL || source == SourceKind.PODCAST
} }
@@ -1,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
@@ -28,6 +28,8 @@ data class PlayerUiState(
val durationMs: Long = 0L, val durationMs: Long = 0L,
val hasNext: Boolean = false, val hasNext: Boolean = false,
val hasPrevious: Boolean = false, val hasPrevious: Boolean = false,
/** D'où vient la file en cours : nom de playlist, d'album, de station. */
val queueLabel: String? = null,
) { ) {
val isIdle: Boolean get() = mediaId == null val isIdle: Boolean get() = mediaId == null
} }
@@ -44,6 +46,7 @@ class PlayerController(
private val resolve: (String) -> MediaEntry?, private val resolve: (String) -> MediaEntry?,
) { ) {
private var controller: MediaController? = null private var controller: MediaController? = null
private var queueLabel: String? = null
private val _state = MutableStateFlow(PlayerUiState()) private val _state = MutableStateFlow(PlayerUiState())
val state: StateFlow<PlayerUiState> = _state.asStateFlow() val state: StateFlow<PlayerUiState> = _state.asStateFlow()
@@ -81,9 +84,10 @@ class PlayerController(
} }
/** Lance une file de lecture à partir de l'entrée choisie. */ /** Lance une file de lecture à partir de l'entrée choisie. */
fun play(queue: List<MediaEntry>, startIndex: Int) { fun play(queue: List<MediaEntry>, startIndex: Int, label: String? = null) {
val controller = controller ?: return val controller = controller ?: return
if (queue.isEmpty()) return if (queue.isEmpty()) return
queueLabel = label
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L) controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
controller.prepare() controller.prepare()
controller.play() controller.play()
@@ -108,6 +112,7 @@ class PlayerController(
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
fun stop() { fun stop() {
queueLabel = null
controller?.stop() controller?.stop()
controller?.clearMediaItems() controller?.clearMediaItems()
syncState() syncState()
@@ -132,6 +137,7 @@ class PlayerController(
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L, durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
hasNext = controller.hasNextMediaItem(), hasNext = controller.hasNextMediaItem(),
hasPrevious = controller.hasPreviousMediaItem(), hasPrevious = controller.hasPreviousMediaItem(),
queueLabel = queueLabel,
) )
} }
} }
@@ -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,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)")
} }
@@ -0,0 +1,64 @@
package fr.tuxgyver.noxely.sync
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
/** Coordonnées du serveur et état de la dernière synchronisation. */
@Serializable
data class ServerConfig(
val baseUrl: String = "",
val token: String = "",
/** Horodatage du dernier média reçu : le serveur ne renvoie que ce qui a changé après. */
val cursor: Long = 0L,
val lastSyncAt: Long = 0L,
val lastResult: String = "",
) {
val isConfigured: Boolean get() = baseUrl.isNotBlank() && token.isNotBlank()
}
/**
* Réglages du serveur, persistés localement.
*
* Le jeton ne quitte jamais l'appareil autrement que dans l'en-tête des requêtes vers le serveur
* désigné : c'est la seule adresse que l'application contacte une fois la bascule faite.
*/
class ServerSettings(context: Context, private val scope: CoroutineScope) {
private val file = File(context.filesDir, "server.json")
private val json = Json { ignoreUnknownKeys = true }
private val _config = MutableStateFlow(ServerConfig())
val config: StateFlow<ServerConfig> = _config.asStateFlow()
init {
scope.launch { _config.value = read() }
}
suspend fun update(transform: (ServerConfig) -> ServerConfig) {
val updated = transform(_config.value)
_config.value = updated
write(updated)
}
private suspend fun read(): ServerConfig = withContext(Dispatchers.IO) {
if (!file.exists()) return@withContext ServerConfig()
runCatching { json.decodeFromString<ServerConfig>(file.readText()) }
.getOrElse { ServerConfig() }
}
private suspend fun write(config: ServerConfig) = withContext(Dispatchers.IO) {
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(json.encodeToString(config))
tmp.renameTo(file)
}
}
@@ -0,0 +1,144 @@
package fr.tuxgyver.noxely.sync
import android.content.Context
import fr.tuxgyver.noxely.library.ImportedStore
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.radio.RecordingSink
import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.URLEncoder
data class SyncResult(val added: Int, val failed: Int, val message: String)
/**
* Client de synchronisation.
*
* Le téléphone ne cherche plus, ne guette plus et n'enregistre plus : il demande au serveur ce
* qui est apparu depuis son dernier passage, télécharge les fichiers prêts, et se rendort. C'est
* là qu'est l'économie de batterie — tout le travail d'écoute et de collecte a migré ailleurs.
*/
class SyncClient(
private val context: Context,
private val settings: ServerSettings,
private val imported: ImportedStore,
) {
private val json = Json { ignoreUnknownKeys = true }
/** Vérifie que le serveur répond et que le jeton est accepté. */
suspend fun probe(baseUrl: String, token: String): Result<String> = withContext(Dispatchers.IO) {
runCatching {
val name = request("${baseUrl.trimEnd('/')}/api/health", token = null)
.let { json.parseToJsonElement(it).jsonObject }
.let { "${it["name"]?.jsonPrimitive?.content} ${it["version"]?.jsonPrimitive?.content}" }
// Une réponse publique ne prouve rien sur le jeton : on interroge aussi un point protégé.
request("${baseUrl.trimEnd('/')}/api/library?since=0", token)
name
}
}
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
val config = settings.config.value
if (!config.isConfigured) return@withContext SyncResult(0, 0, "Serveur non configuré")
val base = config.baseUrl.trimEnd('/')
val body = runCatching { request("$base/api/library?since=${config.cursor}", config.token) }
.getOrElse { return@withContext SyncResult(0, 0, it.message ?: "Serveur injoignable") }
val payload = json.parseToJsonElement(body).jsonObject
val items = (payload["items"] as? JsonArray).orEmpty()
val cursor = payload["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: config.cursor
var added = 0
var failed = 0
for (element in items) {
val item = element as? JsonObject ?: continue
val id = item.text("id") ?: continue
val title = item.text("title") ?: "Sans titre"
val mime = item.text("mime") ?: "audio/mpeg"
val extension = if (mime.contains("aac")) "aac" else if (mime.contains("mp4")) "mp4" else "mp3"
val sink = RecordingSink(context, "${title.take(80)}.$extension", mime)
val outcome = runCatching {
val connection = openConnection("$base/api/media/$id/file", readTimeoutMs = 120_000)
.apply { setRequestProperty("X-Noxely-Token", config.token) }
try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("HTTP ${connection.responseCode}")
}
connection.inputStream.use { input ->
sink.open().use { output -> input.copyTo(output) }
}
} finally {
connection.disconnect()
}
sink.finish(title, item.text("duration_ms")?.toLongOrNull() ?: 0L)
}.getOrNull()
if (outcome == null) {
sink.abort()
failed++
continue
}
val (uri, size) = outcome
imported.add(
listOf(
MediaEntry(
id = "srv:$id",
source = SourceKind.SERVER,
kind = if (mime.startsWith("video")) MediaKind.VIDEO else MediaKind.AUDIO,
title = title,
subtitle = item.text("artist")?.takeIf { it.isNotBlank() } ?: "Serveur Noxely",
uri = uri,
artworkUri = item.text("artwork_url"),
durationMs = item.text("duration_ms")?.toLongOrNull() ?: 0L,
addedAt = System.currentTimeMillis(),
sizeBytes = size,
)
)
)
added++
}
// Le curseur n'avance que si tout est passé : un échec sera retenté au prochain tour.
settings.update {
it.copy(
cursor = if (failed == 0) cursor else it.cursor,
lastSyncAt = System.currentTimeMillis(),
lastResult = when {
added == 0 && failed == 0 -> "À jour"
failed == 0 -> "$added titre(s) reçus"
else -> "$added reçus, $failed en échec"
},
)
}
SyncResult(added, failed, settings.config.value.lastResult)
}
private fun request(url: String, token: String?): String {
val connection = openConnection(url, readTimeoutMs = 30_000).apply {
token?.let { setRequestProperty("X-Noxely-Token", it) }
}
return try {
when (connection.responseCode) {
401 -> throw IllegalStateException("Jeton refusé par le serveur")
in 200..299 -> connection.inputStream.bufferedReader().readText()
else -> throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
}
} finally {
connection.disconnect()
}
}
private fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
private fun JsonObject.text(key: String): String? =
this[key]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() && it != "null" }
}
@@ -0,0 +1,49 @@
package fr.tuxgyver.noxely.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import fr.tuxgyver.noxely.NoxelyApp
import java.util.concurrent.TimeUnit
/**
* Synchronisation planifiée.
*
* Wi-Fi non facturé et batterie non faible : le téléphone ne se réveille que dans les conditions
* où un téléchargement ne coûte rien. C'est le réglage le plus sobre — le serveur, lui, a déjà
* fait tout le travail d'écoute et de collecte.
*/
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val container = (applicationContext as NoxelyApp).container
if (!container.serverSettings.config.value.isConfigured) return Result.success()
val outcome = runCatching { container.sync.sync() }.getOrNull()
return if (outcome == null) Result.retry() else Result.success()
}
companion object {
private const val WORK_NAME = "noxely-sync"
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
}
}
@@ -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,39 +35,32 @@ 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.ui.about.AboutScreen
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen import fr.tuxgyver.noxely.ui.library.LibraryScreen
import fr.tuxgyver.cachette.ui.browse.CatalogItemScreen import fr.tuxgyver.noxely.ui.offline.DownloadsScreen
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen import fr.tuxgyver.noxely.ui.player.MiniPlayer
import fr.tuxgyver.cachette.ui.library.LibraryScreen import fr.tuxgyver.noxely.ui.server.ServerScreen
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen import fr.tuxgyver.noxely.ui.playlists.AddToPlaylistSheet
import fr.tuxgyver.cachette.ui.player.MiniPlayer import fr.tuxgyver.noxely.ui.playlists.PlaylistDetailScreen
import fr.tuxgyver.cachette.ui.radio.RadioScreen import fr.tuxgyver.noxely.ui.playlists.PlaylistsScreen
import fr.tuxgyver.cachette.ui.playlists.AddToPlaylistSheet import fr.tuxgyver.noxely.ui.player.NowPlayingScreen
import fr.tuxgyver.cachette.ui.playlists.PlaylistDetailScreen
import fr.tuxgyver.cachette.ui.playlists.PlaylistsScreen
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
import 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) {
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic), LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload), OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
PLAYLISTS("playlists", "Playlists", Icons.Rounded.PlaylistPlay), PLAYLISTS("playlists", "Playlists", Icons.Rounded.PlaylistPlay),
SOURCES("sources", "Sources", Icons.Rounded.Tune), SERVER("server", "Réglages", Icons.Rounded.Tune),
} }
private const val BROWSE_ROUTE = "browse/{source}"
private const val CATALOG_ROUTE = "catalog/{catalog}"
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
private const val PLAYLIST_ROUTE = "playlist/{playlist}" private const val PLAYLIST_ROUTE = "playlist/{playlist}"
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
@@ -86,6 +79,10 @@ fun CachetteRoot(container: AppContainer) {
container.player.play(queue, index) container.player.play(queue, index)
expanded = true expanded = true
} }
val playFrom: (List<MediaEntry>, Int, String?) -> Unit = { queue, index, label ->
container.player.play(queue, index, label)
expanded = true
}
val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } } val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -145,14 +142,8 @@ fun CachetteRoot(container: AppContainer) {
) )
} }
composable(RADIO_ROUTE) { composable(ABOUT_ROUTE) {
RadioScreen( AboutScreen(onBack = navController::popBackStack, contentPadding = padding)
container = container,
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onMessage = notify,
contentPadding = padding,
)
} }
composable(PLAYLIST_ROUTE) { entry -> composable(PLAYLIST_ROUTE) { entry ->
@@ -161,59 +152,35 @@ fun CachetteRoot(container: AppContainer) {
playlistId = entry.arguments?.getString("playlist").orEmpty(), playlistId = entry.arguments?.getString("playlist").orEmpty(),
currentMediaId = playerState.mediaId, currentMediaId = playerState.mediaId,
onBack = navController::popBackStack, onBack = navController::popBackStack,
onPlay = play, onPlay = playFrom,
contentPadding = padding, contentPadding = padding,
) )
} }
composable(Tab.SOURCES.route) { composable(Tab.SERVER.route) {
SourcesScreen( ServerScreen(
container = container, container = container,
contentPadding = padding, onOpenAbout = { navController.navigate(ABOUT_ROUTE) },
onMessage = notify,
onBrowseSource = { navController.navigate("browse/${it.name}") },
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
onBrowseRadio = { navController.navigate(RADIO_ROUTE) },
)
}
composable(BROWSE_ROUTE) { entry ->
val source = SourceKind.valueOf(entry.arguments?.getString("source") ?: SourceKind.LOCAL.name)
SourceBrowseScreen(
container = container,
source = source,
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onPlay = play,
contentPadding = padding,
)
}
composable(CATALOG_ROUTE) { entry ->
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
CatalogBrowseScreen(
container = container,
catalogId = catalog,
onBack = navController::popBackStack,
onOpenItem = { item ->
container.openedCatalogItem = item
navController.navigate("catalog/${catalog.name}/item")
},
contentPadding = padding,
)
}
composable(CATALOG_ITEM_ROUTE) { entry ->
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
CatalogItemScreen(
container = container,
catalogId = catalog,
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onMessage = notify, onMessage = notify,
contentPadding = padding, contentPadding = padding,
) )
} }
composable(ABOUT_ROUTE) {
AboutScreen(onBack = navController::popBackStack, contentPadding = padding)
}
composable(PLAYLIST_ROUTE) { entry ->
PlaylistDetailScreen(
container = container,
playlistId = entry.arguments?.getString("playlist").orEmpty(),
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onPlay = playFrom,
contentPadding = padding,
)
}
} }
} }
@@ -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,80 @@
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 = "Le serveur collecte, le téléphone écoute."
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 le serveur a collecté est sur le téléphone, lisible dans le métro, en avion " +
"ou forfait épuisé. Audio et vidéo, commandes au casque et sur l'écran verrouillé.",
"Se synchroniser sans y penser" to
"Toutes les six heures, en Wi-Fi non facturé et batterie suffisante, l'application " +
"demande au serveur ce qui est neuf et le récupère. Le reste du temps, elle dort.",
"Laisser le travail au serveur" to
"Recherche dans les catalogues, guet des morceaux sur les radios, enregistrement des " +
"directs : tout cela se pilote depuis l'interface web du serveur, pas depuis le mobile.",
"Organiser en playlists" to
"Des listes nommées, alimentées d'un appui long, lues à la suite.",
"Retrouver les pochettes" to
"Les titres sans image en reçoivent une, cherchée sur leur nom.",
"Rester à jour" to
"L'application surveille les publications du dépôt, télécharge la nouvelle version " +
"en Wi-Fi et propose de l'installer.",
)
/** Chaque promesse est tenue par du code, et dit où. */
val GUARANTEES_INTRO =
"Noxely est un outil personnel : le téléphone ne parle qu'à votre serveur, et rien n'y " +
"transite par un service tiers."
val GUARANTEES = listOf(
"Aucun compte, aucune inscription, aucune télémétrie : le projet n'embarque aucune " +
"bibliothèque d'analyse d'audience.",
"La seule adresse que le mobile contacte est celle de votre serveur, plus le dépôt des " +
"mises à jour. Les catalogues et les radios ne sont joints que par le serveur.",
"Le mobile ne se réveille qu'en Wi-Fi non facturé et batterie suffisante : il ne balaie " +
"plus aucune radio, ne cherche plus et n'enregistre plus.",
"Le cache ne contient que ce que vous avez demandé à garder : rien n'y est écrit pendant " +
"une écoute en ligne, et rien n'en est effacé sans votre geste.",
"Le contenu mis en cache est exclu des sauvegardes dans le nuage et des transferts " +
"d'appareil à appareil.",
"Une synchronisation partielle ne fait pas avancer le curseur : ce qui a échoué est " +
"repris au passage suivant, jamais perdu.",
"Les catalogues proposés ne présentent que des œuvres libres de droits ou du domaine " +
"public ; les éléments en accès restreint sont écartés avant même d'être affichés.",
"Le jeton qui sert à vérifier les mises à jour est en lecture seule et ne peut rien publier.",
)
/** Couche → outils. Six lignes suffisent. */
val STACK = listOf(
"Interface" to "Jetpack Compose, Material 3, thèmes clair et sombre",
"Serveur" to "Python, FastAPI, uvicorn, SQLite, service systemd",
"Lecture" to "Media3 / ExoPlayer, MediaSessionService, notification système",
"Hors connexion" to "Fichiers rangés dans MediaStore, lisibles par tout lecteur",
"Bibliothèque" to "MediaStore pour l'appareil, fichiers JSON pour les imports",
"Synchronisation" to "HTTP avec jeton, curseur incrémental, WorkManager",
"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.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,12 @@ 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.material.icons.rounded.Refresh
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
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 +31,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 +60,11 @@ 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) }
var refreshing by remember { mutableStateOf(false) }
var refreshNote by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var cacheSize by remember { mutableLongStateOf(0L) } 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à.
@@ -88,7 +101,32 @@ fun DownloadsScreen(
cacheSize = cacheSize, cacheSize = cacheSize,
completed = completed, completed = completed,
running = running, running = running,
refreshing = refreshing,
note = refreshNote,
onClear = { confirmClear = true }, onClear = { confirmClear = true },
onRefresh = {
refreshing = true
refreshNote = null
scope.launch {
container.library.refreshLocal()
// Les pochettes manquantes sont cherchées une par une : un enregistrement
// radio ou un fichier importé n'en a aucune tant qu'on ne l'a pas cherchée.
val missing = cached.filter { it.artworkUri.isNullOrBlank() }
var found = 0
missing.take(MAX_ARTWORK_LOOKUPS).forEach { entry ->
container.artwork.find(entry)?.let { url ->
container.imported.setArtwork(entry.id, url)
found++
}
}
refreshing = false
refreshNote = when {
missing.isEmpty() -> "Tout est à jour"
found == 0 -> "Aucune pochette trouvée pour ${missing.size} titre(s)"
else -> "$found pochette(s) ajoutée(s)"
}
}
},
) )
Spacer(Modifier.size(14.dp)) Spacer(Modifier.size(14.dp))
} }
@@ -101,6 +139,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 +149,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 +172,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 },
@@ -147,14 +217,18 @@ private fun StorageCard(
cacheSize: Long, cacheSize: Long,
completed: Int, completed: Int,
running: Int, running: Int,
refreshing: Boolean,
note: String?,
onClear: () -> Unit, onClear: () -> Unit,
onRefresh: () -> Unit,
) { ) {
Surface( Surface(
color = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Row( Column {
Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(20.dp), modifier = Modifier.padding(20.dp),
) { ) {
@@ -171,13 +245,31 @@ private fun StorageCard(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
if (refreshing) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
} else {
IconButton(onClick = onRefresh) {
Icon(Icons.Rounded.Refresh, contentDescription = "Actualiser et chercher les pochettes")
}
}
if (completed > 0 || running > 0) { if (completed > 0 || running > 0) {
TextButton(onClick = onClear) { Text("Tout libérer") } TextButton(onClick = onClear) { Text("Tout libérer") }
} }
}
note?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 14.dp),
)
}
} }
} }
} }
/** Chaque recherche est une requête réseau : on borne le lot pour ne pas marteler le service. */
private const val MAX_ARTWORK_LOOKUPS = 25
@Composable @Composable
private fun EmptyOffline() { private fun EmptyOffline() {
Box( Box(
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.player package fr.tuxgyver.noxely.ui.player
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.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
@@ -36,6 +36,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -54,12 +55,16 @@ 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
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import fr.tuxgyver.noxely.ui.components.formatBytes
@Composable @Composable
fun NowPlayingScreen( fun NowPlayingScreen(
@@ -170,7 +175,12 @@ fun NowPlayingScreen(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.size(20.dp)) entry?.let { current ->
Spacer(Modifier.size(10.dp))
TrackDetails(entry = current, queueLabel = state.queueLabel)
}
Spacer(Modifier.size(16.dp))
Slider( Slider(
value = scrubbing ?: if (state.durationMs > 0) { value = scrubbing ?: if (state.durationMs > 0) {
@@ -264,6 +274,36 @@ private fun SkipButton(
} }
} }
/**
* Ce qu'on sait du morceau en cours : d'où il est lu, ce qu'il pèse, quand il est arrivé.
* Les champs absents ne laissent pas de trou — ils ne s'affichent simplement pas.
*/
@Composable
private fun TrackDetails(entry: MediaEntry, queueLabel: String?) {
val details = buildList {
queueLabel?.takeIf { it.isNotBlank() }?.let { add("📋 $it") }
add(entry.source.label)
entry.sizeBytes?.takeIf { it > 0 }?.let { add(formatBytes(it)) }
entry.addedAt.takeIf { it > 0 }?.let {
add(SimpleDateFormat("d MMM yyyy", Locale.FRANCE).format(Date(it)))
}
}
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
shape = MaterialTheme.shapes.small,
) {
Text(
text = details.joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
}
@Composable @Composable
private fun OfflineToggle( private fun OfflineToggle(
entry: MediaEntry?, entry: MediaEntry?,
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.playlists package fr.tuxgyver.noxely.ui.playlists
import androidx.compose.foundation.clickable import androidx.compose.foundation.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. */
@@ -46,7 +46,7 @@ fun PlaylistDetailScreen(
playlistId: String, playlistId: String,
currentMediaId: String?, currentMediaId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onPlay: (List<MediaEntry>, Int) -> Unit, onPlay: (List<MediaEntry>, Int, String?) -> Unit,
contentPadding: PaddingValues, contentPadding: PaddingValues,
) { ) {
val playlists by container.playlists.playlists.collectAsStateWithLifecycle() val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
@@ -81,7 +81,7 @@ fun PlaylistDetailScreen(
if (tracks.isNotEmpty()) { if (tracks.isNotEmpty()) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
Button(onClick = { onPlay(tracks, 0) }) { Button(onClick = { onPlay(tracks, 0, playlist?.name) }) {
Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
Text("Tout lire") Text("Tout lire")
@@ -115,7 +115,7 @@ fun PlaylistDetailScreen(
entry = entry, entry = entry,
offline = offlineStates[entry.id], offline = offlineStates[entry.id],
isCurrent = entry.id == currentMediaId, isCurrent = entry.id == currentMediaId,
onClick = { onPlay(tracks, tracks.indexOf(entry)) }, onClick = { onPlay(tracks, tracks.indexOf(entry), playlist?.name) },
onDownload = { container.downloads.download(entry) }, onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) }, onRemoveOffline = { container.downloads.remove(entry.id) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.playlists package fr.tuxgyver.noxely.ui.playlists
import androidx.compose.foundation.clickable import androidx.compose.foundation.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
@@ -0,0 +1,253 @@
package fr.tuxgyver.noxely.ui.server
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.BuildConfig
import fr.tuxgyver.noxely.ui.sources.UpdateCard
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val SETTINGS_TABS = listOf("🔌 Connexion", "🔄 Synchronisation", "📱 Application")
/**
* Réglages, rangés par catégorie.
*
* L'écran mêlait la connexion au serveur, l'état des synchronisations et les affaires de
* l'application elle-même. Trois sujets, trois onglets : on ne défile plus pour trouver.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerScreen(
container: AppContainer,
onOpenAbout: () -> Unit,
onMessage: (String) -> Unit,
contentPadding: PaddingValues,
) {
var tab by rememberSaveable { mutableIntStateOf(0) }
val config by container.serverSettings.config.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var url by remember { mutableStateOf("") }
var token by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var probe by remember { mutableStateOf<String?>(null) }
LaunchedEffect(config.baseUrl, config.token) {
if (url.isBlank()) url = config.baseUrl
if (token.isBlank()) token = config.token
}
Column(Modifier.fillMaxSize()) {
Column(Modifier.padding(horizontal = 16.dp).padding(top = 12.dp, bottom = 8.dp)) {
Text("Réglages", style = MaterialTheme.typography.displaySmall)
Text(
text = "Le serveur cherche, guette les radios et enregistre. Le téléphone ne fait " +
"que récupérer les fichiers prêts — c'est ce qui préserve la batterie.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ScrollableTabRow(selectedTabIndex = tab, edgePadding = 16.dp) {
SETTINGS_TABS.forEachIndexed { index, label ->
Tab(
selected = tab == index,
onClick = { tab = index },
text = { Text(label, style = MaterialTheme.typography.labelLarge) },
)
}
}
LazyColumn(
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
) {
item { Spacer(Modifier.size(4.dp)) }
when (tab) {
0 -> {
item {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.large,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(20.dp)) {
OutlinedTextField(
value = url,
onValueChange = { url = it; probe = null },
label = { Text("Adresse du serveur") },
placeholder = { Text("http://192.168.1.20:8710") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(10.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it; probe = null },
label = { Text("Jeton") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Button(
enabled = !busy && url.isNotBlank() && token.isNotBlank(),
onClick = {
busy = true
probe = null
scope.launch {
container.sync.probe(url.trim(), token.trim()).fold(
onSuccess = { name ->
container.serverSettings.update {
it.copy(baseUrl = url.trim(), token = token.trim())
}
probe = "Connecté à $name"
},
onFailure = { probe = it.message ?: "Échec de connexion" },
)
busy = false
}
},
) { Text("Tester et enregistrer") }
Spacer(Modifier.width(10.dp))
if (busy) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
} else if (config.isConfigured) {
TextButton(onClick = {
busy = true
scope.launch {
val result = container.sync.sync()
busy = false
onMessage(result.message)
}
}) { Text("Synchroniser") }
}
}
probe?.let {
Spacer(Modifier.size(10.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = if (it.startsWith("Connecté")) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.error,
)
}
}
}
}
}
1 -> {
item {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.CloudSync, contentDescription = null)
Spacer(Modifier.width(14.dp))
Column {
Text("Synchronisation automatique", style = MaterialTheme.typography.titleSmall)
Text(
text = "Toutes les six heures, en Wi-Fi non facturé et batterie suffisante.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (config.lastSyncAt > 0) {
Text(
text = "Dernière : ${
SimpleDateFormat("d MMM HH:mm", Locale.FRANCE)
.format(Date(config.lastSyncAt))
} — ${config.lastResult}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
}
}
else -> {
item { UpdateCard(container) }
item {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.large,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Info, contentDescription = null)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text("À propos", style = MaterialTheme.typography.titleMedium)
Text(
text = "Version ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
)
}
TextButton(onClick = onOpenAbout) { Text("Ouvrir") }
}
}
}
}
}
item { Spacer(Modifier.size(24.dp)) }
}
}
}
@@ -1,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>
+19
View File
@@ -0,0 +1,19 @@
# Jeton partagé entre le serveur et l'application Android. Sans lui, l'API refuse tout.
# Générer par exemple avec : openssl rand -base64 24
NOXELY_TOKEN=
# Où sont rangés la base et les fichiers média.
# Par défaut dans le projet, pour que `make server-run` fonctionne sans droits particuliers.
# En service systemd, /var/lib/noxely est plus propre : l'unité le crée elle-même
# (StateDirectory), inutile de le préparer à la main.
NOXELY_DATA=./data
# Interface et port d'écoute.
NOXELY_HOST=0.0.0.0
NOXELY_PORT=8710
# Balayage des radios. Il est continu : un tour enchaîne sur le suivant.
# NOXELY_WATCH_INTERVAL n'est qu'une pause de sécurité entre deux tours.
NOXELY_WATCH_STATIONS=40
NOXELY_WATCH_INTERVAL=5
NOXELY_WATCH_CONCURRENCY=8
+44
View File
@@ -0,0 +1,44 @@
# 🖥️ Noxely — serveur
Le serveur porte tout ce qui coûtait de la batterie au téléphone : la recherche dans les
catalogues, le guet des radios, l'enregistrement et le téléchargement. Le mobile ne fait plus que
récupérer des fichiers prêts.
## Installation
```bash
make server-venv # crée le venv et installe les dépendances
cp server/.env.example server/.env # puis renseigner NOXELY_TOKEN
make server-run # lancement au premier plan, pour essayer
```
L'interface de pilotage est à `http://<hôte>:8710` — recherche, guets, file et bibliothèque.
Le jeton se saisit une fois dans l'en-tête de la page.
## En service
```bash
make server-install # unité systemd (sudo demandé)
make server-status # état
make server-logs # journal suivi
```
## API
| Route | Qui l'utilise |
|---|---|
| `GET /api/health` | tout le monde, sans jeton |
| `GET /api/library?since=<curseur>` | **le mobile** — uniquement ce qui a changé |
| `GET /api/media/{id}/file` | **le mobile** — le fichier |
| `GET /api/search`, `/api/tracks`, `POST /api/queue` | l'interface web |
| `GET/POST/DELETE /api/watches`, `POST /api/watches/sweep` | l'interface web |
Toutes les routes sauf `/api/health` exigent l'en-tête `X-Noxely-Token`.
## Données
```
$NOXELY_DATA/
├── noxely.sqlite3 index des médias et des guets
└── media/ les fichiers eux-mêmes
```
+27
View File
@@ -0,0 +1,27 @@
# Noxely — serveur : recherche, guet radio, enregistrement et mise à disposition hors ligne.
# Installé par `make server-install` : les marqueurs @...@ sont remplacés à l'installation,
# jamais figés dans le dépôt — le chemin et l'utilisateur diffèrent d'une machine à l'autre.
[Unit]
Description=Noxely - serveur de collecte et de synchronisation
Documentation=file://@PROJECT@/server/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=@USER@
WorkingDirectory=@PROJECT@/server
# NOXELY_TOKEN, NOXELY_DATA, NOXELY_HOST, NOXELY_PORT
EnvironmentFile=@PROJECT@/server/.env
Environment=PYTHONUNBUFFERED=1
# Crée /var/lib/noxely avec les bons droits : rien à préparer à la main.
StateDirectory=noxely
ExecStart=@PROJECT@/server/venv/bin/python -m noxely_server
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
View File
Whitespace-only changes.
+44
View File
@@ -0,0 +1,44 @@
"""Point d'entrée : `python -m noxely_server`."""
from __future__ import annotations
import sys
import uvicorn
from .config import load
def ensure_usable(data_dir) -> None:
"""Le dossier de données est la première chose qui peut manquer : on le dit clairement."""
try:
data_dir.mkdir(parents=True, exist_ok=True)
probe = data_dir / ".ecriture"
probe.touch()
probe.unlink()
except PermissionError:
print(
f"[noxely] Droits insuffisants sur {data_dir}.\n"
f" Choisissez un dossier accessible dans server/.env (NOXELY_DATA=./data),\n"
f" ou créez-le : sudo install -d -o $(whoami) {data_dir}",
file=sys.stderr,
)
raise SystemExit(1)
except OSError as error:
print(f"[noxely] Dossier de données inutilisable ({data_dir}) : {error}", file=sys.stderr)
raise SystemExit(1)
def main() -> None:
config = load()
ensure_usable(config.data_dir)
# Importé après la vérification : l'application ouvre la base dès son chargement.
from .api import app
print(f"[noxely] données : {config.data_dir}")
print(f"[noxely] écoute : http://{config.host}:{config.port}")
uvicorn.run(app, host=config.host, port=config.port, log_level="info")
if __name__ == "__main__":
main()
+198
View File
@@ -0,0 +1,198 @@
"""API HTTP du serveur Noxely.
Le client Android ne consomme que `/api/health`, `/api/library` et `/api/media/{id}/file` :
tout le reste sert au pilotage depuis un navigateur. Cette séparation est volontaire — le
mobile doit pouvoir se contenter d'un sondage bref, c'est là qu'est le gain de batterie.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse
from . import config as config_module
from .sources import catalogs, radio
from .storage import Storage
from .workers import Downloader, Watcher, safe_name
VERSION = "1.0.0"
WEB_DIR = Path(__file__).parent / "web"
config = config_module.load()
storage = Storage(config.db_path)
downloader = Downloader(config, storage)
watcher = Watcher(config, storage)
def authorised(x_noxely_token: str = Header(default="")) -> None:
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers."""
if x_noxely_token != config.token:
raise HTTPException(status_code=401, detail="Jeton invalide")
@asynccontextmanager
async def lifespan(app: FastAPI):
tasks = [
asyncio.create_task(downloader.run_forever()),
asyncio.create_task(watcher.run_forever()),
]
yield
for task in tasks:
task.cancel()
app = FastAPI(title="Noxely", version=VERSION, lifespan=lifespan)
# --- Ce que consomme le mobile ------------------------------------------
@app.get("/api/health")
def health() -> dict:
return {"name": "Noxely", "version": VERSION}
@app.get("/api/library", dependencies=[Depends(authorised)])
def library(since: int = Query(default=0)) -> dict:
"""Uniquement ce qui a changé depuis le dernier passage du client."""
items = storage.media_since(since)
return {
"items": [m.public() for m in items],
"cursor": items[-1].updated_at if items else since,
}
@app.get("/api/media/{media_id}/file", dependencies=[Depends(authorised)])
def media_file(media_id: str):
item = storage.media(media_id)
if not item or item.state != "ready":
raise HTTPException(status_code=404, detail="Média indisponible")
path = config.media_dir / item.filename
if not path.exists():
raise HTTPException(status_code=404, detail="Fichier absent")
return FileResponse(path, media_type=item.mime, filename=item.filename)
@app.delete("/api/media/{media_id}", dependencies=[Depends(authorised)])
def delete_media(media_id: str) -> dict:
item = storage.delete_media(media_id)
if item:
(config.media_dir / item.filename).unlink(missing_ok=True)
return {"deleted": bool(item)}
# --- Pilotage depuis le navigateur --------------------------------------
@app.get("/api/catalogs", dependencies=[Depends(authorised)])
def list_catalogs() -> dict:
return {
"catalogs": {"all": "Toutes les sources", **catalogs.CATALOGS},
"genres": radio.GENRES,
}
@app.get("/api/search", dependencies=[Depends(authorised)])
async def search(catalog: str, q: str, language: str | None = None) -> dict:
try:
if catalog == "all":
return {"results": await catalogs.search_all(q, language)}
return {"results": await catalogs.search(catalog, q, language)}
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)[:200])
@app.get("/api/tracks", dependencies=[Depends(authorised)])
async def tracks(catalog: str, id: str, title: str = "", kind: str = "Audio") -> dict:
try:
return {"tracks": await catalogs.tracks(catalog, id, title, kind)}
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)[:200])
@app.post("/api/queue", dependencies=[Depends(authorised)])
def queue(payload: dict = Body(...)) -> dict:
"""Met des pistes en file : le téléchargement se fait en tâche de fond."""
added = 0
for track in payload.get("tracks", []):
url = track.get("url")
if not url:
continue
extension = url.split("?")[0].rsplit(".", 1)[-1].lower()
if extension not in ("mp3", "m4a", "ogg", "opus", "flac", "wav", "mp4", "webm"):
extension = "mp3"
storage.add_media(
title=track.get("title") or "Sans titre",
artist=track.get("artist") or "",
source=payload.get("catalog") or "catalogue",
origin=payload.get("origin") or "",
filename=safe_name(track.get("title") or "media", extension),
remote_url=url,
duration_ms=int(track.get("duration_ms") or 0),
artwork_url=track.get("artwork_url"),
)
added += 1
return {"queued": added}
@app.get("/api/radio/stations", dependencies=[Depends(authorised)])
async def radio_stations(genre: str | None = None, name: str | None = None) -> dict:
return {"stations": await radio.stations(genre, name)}
@app.get("/api/watches", dependencies=[Depends(authorised)])
def watches() -> dict:
return {
"watches": storage.watches(),
"recording": watcher.recording,
"sweeps": watcher.sweeps,
"last_sweep": watcher.last_sweep,
}
@app.post("/api/watches", dependencies=[Depends(authorised)])
def add_watch(payload: dict = Body(...)) -> dict:
query = (payload.get("query") or "").strip()
if not query:
raise HTTPException(status_code=400, detail="Requête vide")
return {"id": storage.add_watch(query)}
@app.delete("/api/watches/{watch_id}", dependencies=[Depends(authorised)])
def delete_watch(watch_id: str) -> dict:
storage.delete_watch(watch_id)
return {"deleted": True}
@app.post("/api/watches/sweep", dependencies=[Depends(authorised)])
async def sweep() -> dict:
try:
return await watcher.sweep()
except Exception as error:
# L'annuaire peut être momentanément injoignable : le dire, pas renvoyer une trace.
raise HTTPException(status_code=502, detail=f"Annuaire indisponible : {str(error)[:120]}")
@app.get("/api/queue", dependencies=[Depends(authorised)])
def queue_state() -> dict:
pending = storage.media_since(0, state=None)
return {"items": [m.public() for m in pending if m.state != "ready"][:50]}
@app.get("/api/bootstrap")
def bootstrap(request: Request) -> dict:
"""Livre le jeton — uniquement à une requête venue de la machine elle-même.
Depuis un autre poste, il faut le saisir : c'est précisément ce que le jeton protège.
`make server-token` l'affiche pour le copier.
"""
client = request.client.host if request.client else ""
if client not in ("127.0.0.1", "::1", "localhost"):
raise HTTPException(status_code=403, detail="Jeton lisible seulement en local")
return {"token": config.token}
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return (WEB_DIR / "index.html").read_text(encoding="utf-8")
+67
View File
@@ -0,0 +1,67 @@
"""Configuration du serveur Noxely, lue de l'environnement.
Aucune valeur sensible n'est écrite dans le dépôt : le jeton et les chemins viennent d'un
fichier .env local, à l'image des autres services de la maison.
"""
from __future__ import annotations
import os
import secrets
from dataclasses import dataclass
from pathlib import Path
def _persisted_token(data_dir: Path) -> str:
"""Jeton auto-généré, conservé entre deux démarrages.
Un jeton tiré à chaque lancement obligerait à reconfigurer le téléphone après le moindre
redémarrage : il est donc écrit une fois, en lecture seule pour son propriétaire.
"""
path = data_dir / "token"
if path.exists():
existing = path.read_text().strip()
if existing:
return existing
token = secrets.token_urlsafe(24)
data_dir.mkdir(parents=True, exist_ok=True)
path.write_text(token + "\n")
path.chmod(0o600)
print(f"[noxely] jeton généré et conservé dans {path}")
return token
@dataclass(frozen=True)
class Config:
data_dir: Path
token: str
host: str
port: int
#: Nombre de stations balayées à chaque passage du guet.
watch_stations: int
#: Pause entre deux balayages, en secondes. Le balayage est continu : cette pause n'est
#: qu'un garde-fou pour ne pas marteler l'annuaire si un tour échoue instantanément.
watch_interval: int
#: Stations sondées en parallèle. Plus il est haut, plus un tour complet est bref.
watch_concurrency: int
@property
def media_dir(self) -> Path:
return self.data_dir / "media"
@property
def db_path(self) -> Path:
return self.data_dir / "noxely.sqlite3"
def load() -> Config:
data_dir = Path(os.environ.get("NOXELY_DATA", "./data")).expanduser().resolve()
token = os.environ.get("NOXELY_TOKEN", "").strip() or _persisted_token(data_dir)
return Config(
data_dir=data_dir,
token=token,
host=os.environ.get("NOXELY_HOST", "0.0.0.0"),
port=int(os.environ.get("NOXELY_PORT", "8710")),
watch_stations=int(os.environ.get("NOXELY_WATCH_STATIONS", "40")),
watch_interval=int(os.environ.get("NOXELY_WATCH_INTERVAL", "5")),
watch_concurrency=int(os.environ.get("NOXELY_WATCH_CONCURRENCY", "8")),
)
+6
View File
@@ -0,0 +1,6 @@
"""Catalogues libres et annuaire radio, portés depuis le client Android.
Les pièges vérifiés côté mobile restent valables ici et sont conservés en commentaire :
LibriVox ignore son paramètre `language`, archive.org sert des éléments en accès restreint,
et un titre ICY court jusqu'au « '; » et non jusqu'à la première apostrophe.
"""
+235
View File
@@ -0,0 +1,235 @@
"""Recherche dans les catalogues libres de droits."""
from __future__ import annotations
import asyncio
import re
import urllib.parse
import httpx
# Wikimedia refuse (403) les agents génériques : sa politique demande un nom identifiable
# et un moyen de contact. Les autres services s'en accommodent aussi.
UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"}
TIMEOUT = httpx.Timeout(30.0, connect=15.0)
AUDIO_EXT = (".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav")
VIDEO_EXT = (".mp4", ".m4v", ".webm", ".ogv")
CATALOGS = {
"archive": "Internet Archive",
"underground": "Underground (netlabels)",
"librivox": "LibriVox",
"wikimedia": "Wikimedia Commons",
}
async def search_all(query: str, language: str | None = None) -> list[dict]:
"""Interroge les quatre catalogues en parallèle et fond les résultats.
Chaque ligne porte son catalogue d'origine : c'est lui qu'il faudra pour en lister les
pistes. Un catalogue en panne n'empêche pas les autres de répondre — on préfère une liste
incomplète à une erreur globale.
"""
names = list(CATALOGS)
outcomes = await asyncio.gather(
*(search(name, query, language) for name in names), return_exceptions=True
)
merged: list[dict] = []
for name, outcome in zip(names, outcomes):
if isinstance(outcome, Exception):
continue
for item in outcome:
merged.append({**item, "catalog": name})
# Les résultats les plus légers d'abord : sur un téléphone, la taille prime.
merged.sort(key=lambda item: item["size"] or 1 << 62)
return merged
async def search(catalog: str, query: str, language: str | None = None) -> list[dict]:
if catalog == "librivox":
return await _librivox(query, language)
if catalog == "wikimedia":
return await _wikimedia(query)
collections = ["netlabels", "freemusicarchive"] if catalog == "underground" else []
return await _archive(query, language, collections)
async def tracks(catalog: str, item_id: str, title: str = "", kind: str = "Audio") -> list[dict]:
"""Les fichiers d'un résultat : un album en a plusieurs, un morceau un seul."""
if catalog == "librivox":
return await _librivox_tracks(item_id)
if catalog == "wikimedia":
return [{"title": title or item_id, "artist": "Wikimedia Commons", "url": item_id, "size": 0}]
return await _archive_tracks(item_id, title, kind)
# --- archive.org --------------------------------------------------------
async def _archive(query: str, language: str | None, collections: list[str]) -> list[dict]:
clauses = [f"({query})", "mediatype:(audio OR movies)"]
if language:
clauses.append(f"language:({language})")
if collections:
clauses.append("collection:(" + " OR ".join(collections) + ")")
# Les éléments restreints répondent 401 au téléchargement : écartés dès la recherche.
clauses.append("NOT access-restricted-item:true")
params = {
"q": " AND ".join(clauses),
"fl[]": ["identifier", "title", "creator", "mediatype", "item_size"],
"rows": "30", "page": "1", "output": "json",
}
async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client:
response = await client.get("https://archive.org/advancedsearch.php", params=params)
response.raise_for_status()
docs = response.json().get("response", {}).get("docs", [])
return [
{
"id": doc["identifier"],
"title": _first(doc.get("title")) or doc["identifier"],
"artist": _first(doc.get("creator")) or "Internet Archive",
"kind": "Vidéo" if doc.get("mediatype") == "movies" else "Audio",
"catalog": "archive",
"size": int(doc.get("item_size") or 0),
}
for doc in docs if doc.get("identifier")
]
async def _archive_tracks(identifier: str, title: str, kind: str) -> list[dict]:
async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT, follow_redirects=True) as client:
response = await client.get(f"https://archive.org/metadata/{identifier}")
response.raise_for_status()
meta = response.json()
if str(meta.get("metadata", {}).get("access-restricted-item", "")).lower() == "true":
raise ValueError(f"« {title or identifier} » est en accès restreint")
wants_video = kind == "Vidéo"
extensions = VIDEO_EXT if wants_video else AUDIO_EXT
files = [f for f in meta.get("files", []) if f.get("name", "").lower().endswith(extensions)]
if not files:
raise ValueError("Aucun fichier lisible dans cet élément")
# Un élément expose l'original et ses dérivés : on garde le plus léger de chaque morceau,
# autant de pistes que d'œuvres et non que de formats.
groups: dict[str, dict] = {}
for entry in files:
stem = entry["name"].rsplit(".", 1)[0].lower()
size = int(entry.get("size") or 1 << 62)
if stem not in groups or size < int(groups[stem].get("size") or 1 << 62):
groups[stem] = entry
out = []
for entry in sorted(groups.values(), key=lambda f: f["name"]):
name = entry["name"]
out.append({
"title": entry.get("title") or name.rsplit(".", 1)[0],
"artist": title or identifier,
"url": f"https://archive.org/download/{identifier}/" + urllib.parse.quote(name),
"size": int(entry.get("size") or 0),
"duration_ms": int(float(entry.get("length") or 0) * 1000),
"artwork_url": f"https://archive.org/services/img/{identifier}",
})
return out
# --- LibriVox -----------------------------------------------------------
async def _librivox(query: str, language: str | None) -> list[dict]:
term = urllib.parse.quote(query.strip())
words = query.strip().split()
# `title` est une correspondance exacte sans « ^ », et `author` ne matche que le nom de
# famille : trois interrogations valent mieux qu'une qui échouerait.
criteria = [f"title=^{term}", f"author={term}"]
if len(words) > 1:
criteria.append("author=" + urllib.parse.quote(words[-1]))
seen: dict[str, dict] = {}
limit = 25 if not language else 100
async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client:
for criterion in criteria:
url = f"https://librivox.org/api/feed/audiobooks/?format=json&limit={limit}&{criterion}"
response = await client.get(url)
if response.status_code == 404: # « aucun résultat », pas une panne
continue
response.raise_for_status()
for book in response.json().get("books", []):
# Le paramètre `language` de l'API est accepté puis ignoré : filtrage ici.
if language and (book.get("language") or "").lower() != language.lower():
continue
feed = book.get("url_rss")
if feed and feed not in seen:
authors = ", ".join(
" ".join(filter(None, [a.get("first_name"), a.get("last_name")]))
for a in book.get("authors", [])
)
seen[feed] = {
"id": feed,
"title": book.get("title") or "Livre audio",
"artist": authors or "LibriVox",
"kind": "Audio",
"size": 0,
"catalog": "librivox",
}
return list(seen.values())
async def _librivox_tracks(feed_url: str) -> list[dict]:
async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT, follow_redirects=True) as client:
response = await client.get(feed_url)
response.raise_for_status()
xml = response.text
title = re.search(r"<title>(.*?)</title>", xml, re.S)
book = (title.group(1).strip() if title else "Livre audio")
out = []
for item in re.findall(r"<item>(.*?)</item>", xml, re.S):
url = re.search(r'<enclosure[^>]*url="([^"]+)"', item)
name = re.search(r"<title>(.*?)</title>", item, re.S)
if url:
out.append({
"title": (name.group(1).strip() if name else "Épisode"),
"artist": book,
"url": url.group(1),
"size": 0,
})
return out
# --- Wikimedia Commons --------------------------------------------------
async def _wikimedia(query: str) -> list[dict]:
params = {
"action": "query", "format": "json", "generator": "search",
"gsrsearch": f"filetype:audio {query.strip()}", "gsrnamespace": "6", "gsrlimit": "30",
"prop": "imageinfo", "iiprop": "url|size|mime|extmetadata",
}
async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client:
response = await client.get("https://commons.wikimedia.org/w/api.php", params=params)
response.raise_for_status()
pages = response.json().get("query", {}).get("pages", {})
out = []
for page in pages.values():
info = (page.get("imageinfo") or [{}])[0]
url = info.get("url")
if not url or not url.split("?")[0].lower().endswith(AUDIO_EXT):
continue
licence = (info.get("extmetadata", {}).get("LicenseShortName", {}) or {}).get("value")
out.append({
"id": url,
"title": page.get("title", "").removeprefix("File:").rsplit(".", 1)[0],
"artist": licence or "Wikimedia Commons",
"kind": "Audio",
"size": int(info.get("size") or 0),
"catalog": "wikimedia",
})
return out
def _first(value):
if isinstance(value, list):
return value[0] if value else None
return value
+92
View File
@@ -0,0 +1,92 @@
"""Annuaire des stations et lecture des métadonnées ICY."""
from __future__ import annotations
import asyncio
import re
import urllib.parse
import httpx
UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"}
BASE = "https://de1.api.radio-browser.info/json/stations/search"
#: Un titre ICY court jusqu'au « '; » : les apostrophes y sont trop fréquentes pour s'arrêter
#: à la première (« Ain't That a Kick in the Head » deviendrait « Ain »).
STREAM_TITLE = re.compile(r"StreamTitle='(.*?)';")
GENRES = ["pop", "rock", "jazz", "classical", "electronic", "news", "chanson"]
async def stations(genre: str | None = None, name: str | None = None, limit: int = 60) -> list[dict]:
params = {"limit": str(limit), "hidebroken": "true", "order": "clickcount", "reverse": "true"}
if genre:
params["tag"] = genre
if name:
params["name"] = name
# L'annuaire est un service communautaire : une coupure passagère ne doit pas faire
# tomber le balayage. Mesuré en conditions réelles, d'où cette seconde tentative.
rows = None
async with httpx.AsyncClient(headers=UA, timeout=httpx.Timeout(30.0)) as client:
for attempt in range(2):
try:
response = await client.get(BASE, params=params)
response.raise_for_status()
rows = response.json()
break
except Exception:
if attempt == 1:
raise
await asyncio.sleep(2)
out = []
for row in rows:
url = row.get("url_resolved") or ""
# Les flux HLS se lisent mais ne s'enregistrent pas en copiant les octets.
if int(row.get("hls") or 0) != 0 or not url.startswith("http"):
continue
out.append({
"id": row.get("stationuuid") or url,
"name": (row.get("name") or "Station").strip(),
"url": url,
"codec": row.get("codec") or "",
"bitrate": int(row.get("bitrate") or 0),
"country": row.get("country") or "",
})
return out
async def now_playing(stream_url: str) -> str | None:
"""Le morceau diffusé à l'instant : aucun annuaire ne l'expose, il faut ouvrir le flux."""
headers = {**UA, "Icy-MetaData": "1"}
try:
async with httpx.AsyncClient(headers=headers, timeout=httpx.Timeout(10.0)) as client:
async with client.stream("GET", stream_url) as response:
if response.status_code >= 300:
return None
interval = int(response.headers.get("icy-metaint") or 0)
if interval <= 0:
return None
return await _read_title(response, interval)
except Exception:
return None
async def _read_title(response, interval: int) -> str | None:
# Client asynchrone : `iter_bytes` n'itère rien ici, il faut `aiter_bytes`.
buffer = bytearray()
needed = interval + 1
async for chunk in response.aiter_bytes():
buffer.extend(chunk)
if len(buffer) < needed:
continue
length = buffer[interval] * 16
if length == 0:
return None
needed = interval + 1 + length
if len(buffer) < needed:
continue
meta = bytes(buffer[interval + 1:needed]).decode("utf-8", "replace")
found = STREAM_TITLE.search(meta)
return found.group(1).strip() or None if found else None
return None
+179
View File
@@ -0,0 +1,179 @@
"""Index des médias et des guets, en SQLite.
Les fichiers vivent sur le disque ; la base ne porte que leurs métadonnées et l'état de la
synchronisation. Un client ne demande que ce qui a changé depuis son dernier passage, d'où le
champ `updated_at` sur chaque ligne.
"""
from __future__ import annotations
import sqlite3
import time
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
SCHEMA = """
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
origin TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT 'audio/mpeg',
size INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
artwork_url TEXT,
state TEXT NOT NULL DEFAULT 'pending',
error TEXT,
remote_url TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS media_updated ON media(updated_at);
CREATE TABLE IF NOT EXISTS watches (
id TEXT PRIMARY KEY,
query TEXT NOT NULL,
created_at INTEGER NOT NULL,
scans INTEGER NOT NULL DEFAULT 0,
last_scan INTEGER,
found_at INTEGER,
found_on TEXT,
found_title TEXT
);
"""
@dataclass
class Media:
id: str
title: str
artist: str
source: str
origin: str
filename: str
mime: str
size: int
duration_ms: int
artwork_url: str | None
state: str
error: str | None
remote_url: str | None
created_at: int
updated_at: int
def public(self) -> dict:
"""Vue destinée au client : ni chemin local, ni URL d'origine."""
data = asdict(self)
data.pop("remote_url")
data.pop("filename")
return data
def now() -> int:
return int(time.time() * 1000)
class Storage:
def __init__(self, db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
self._db = sqlite3.connect(db_path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._db.executescript(SCHEMA)
self._db.commit()
# --- Médias ---------------------------------------------------------
def add_media(self, *, title: str, artist: str, source: str, origin: str,
filename: str, remote_url: str | None, mime: str = "audio/mpeg",
duration_ms: int = 0, artwork_url: str | None = None,
state: str = "pending") -> str:
media_id = uuid.uuid4().hex
stamp = now()
self._db.execute(
"INSERT INTO media (id,title,artist,source,origin,filename,mime,size,duration_ms,"
"artwork_url,state,error,remote_url,created_at,updated_at) "
"VALUES (?,?,?,?,?,?,?,0,?,?,?,NULL,?,?,?)",
(media_id, title, artist, source, origin, filename, mime, duration_ms,
artwork_url, state, remote_url, stamp, stamp),
)
self._db.commit()
return media_id
def update_media(self, media_id: str, **fields) -> None:
if not fields:
return
fields["updated_at"] = now()
assignments = ", ".join(f"{key} = ?" for key in fields)
self._db.execute(
f"UPDATE media SET {assignments} WHERE id = ?", (*fields.values(), media_id)
)
self._db.commit()
def media(self, media_id: str) -> Media | None:
row = self._db.execute("SELECT * FROM media WHERE id = ?", (media_id,)).fetchone()
return Media(**dict(row)) if row else None
def media_since(self, since: int = 0, state: str | None = "ready") -> list[Media]:
query = "SELECT * FROM media WHERE updated_at > ?"
params: list = [since]
if state:
query += " AND state = ?"
params.append(state)
query += " ORDER BY updated_at ASC"
return [Media(**dict(r)) for r in self._db.execute(query, params)]
def pending_downloads(self) -> list[Media]:
return [
Media(**dict(r))
for r in self._db.execute(
"SELECT * FROM media WHERE state IN ('pending','failed') AND remote_url IS NOT NULL "
"ORDER BY created_at ASC LIMIT 20"
)
]
def delete_media(self, media_id: str) -> Media | None:
item = self.media(media_id)
if item:
self._db.execute("DELETE FROM media WHERE id = ?", (media_id,))
self._db.commit()
return item
# --- Guets ----------------------------------------------------------
def add_watch(self, query: str) -> str:
watch_id = uuid.uuid4().hex
self._db.execute(
"INSERT INTO watches (id,query,created_at) VALUES (?,?,?)",
(watch_id, query.strip(), now()),
)
self._db.commit()
return watch_id
def watches(self) -> list[dict]:
return [dict(r) for r in self._db.execute("SELECT * FROM watches ORDER BY created_at DESC")]
def pending_watches(self) -> list[dict]:
return [dict(r) for r in self._db.execute("SELECT * FROM watches WHERE found_at IS NULL")]
def mark_watch_found(self, watch_id: str, station: str, title: str) -> None:
self._db.execute(
"UPDATE watches SET found_at=?, found_on=?, found_title=? WHERE id=?",
(now(), station, title, watch_id),
)
self._db.commit()
def mark_watches_scanned(self, ids: list[str]) -> None:
if not ids:
return
stamp = now()
self._db.executemany(
"UPDATE watches SET scans = scans + 1, last_scan = ? WHERE id = ?",
[(stamp, i) for i in ids],
)
self._db.commit()
def delete_watch(self, watch_id: str) -> None:
self._db.execute("DELETE FROM watches WHERE id = ?", (watch_id,))
self._db.commit()
+267
View File
@@ -0,0 +1,267 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noxely — serveur</title>
<style>
:root { --bg:#faf7ff; --card:#fff; --ink:#1c1b22; --muted:#5c5670; --accent:#6b4eff;
--mint:#11a894; --line:#e6e0f0; }
@media (prefers-color-scheme: dark) {
:root { --bg:#120d1c; --card:#1b1033; --ink:#e8e0f0; --muted:#a79fbb; --line:#2f2743; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 system-ui,sans-serif; }
header { padding:18px 20px; border-bottom:1px solid var(--line); display:flex;
align-items:center; gap:12px; flex-wrap:wrap; }
h1 { font-size:19px; margin:0; }
main { max-width:920px; margin:0 auto; padding:20px; }
.tabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:18px; }
.tabs button { background:none; border:1px solid var(--line); border-radius:999px;
padding:7px 15px; color:var(--muted); cursor:pointer; font-size:14px; }
.tabs button[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
.card { background:var(--card); border:1px solid var(--line); border-radius:14px;
padding:12px 14px; margin-bottom:8px; display:flex; gap:12px; align-items:center; }
.card .grow { flex:1; min-width:0; }
.card b { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.card small { color:var(--muted); }
input, select { padding:9px 12px; border:1px solid var(--line); border-radius:10px;
background:var(--card); color:var(--ink); font-size:14px; }
input[type=text] { flex:1; min-width:160px; }
button.go { background:var(--accent); color:#fff; border:none; border-radius:10px;
padding:9px 16px; cursor:pointer; font-size:14px; }
button.ghost { background:none; border:1px solid var(--line); border-radius:10px;
padding:7px 12px; cursor:pointer; color:var(--muted); }
.row { display:flex; gap:8px; margin-bottom:14px; flex-wrap:wrap; }
.pill { font-size:12px; padding:3px 9px; border-radius:999px; background:var(--line); color:var(--muted); }
.pill.ok { background:rgba(17,168,148,.16); color:var(--mint); }
.pill.warn { background:rgba(224,98,155,.16); color:#e0629b; }
.empty { color:var(--muted); text-align:center; padding:40px 10px; }
</style>
</head>
<body>
<header>
<h1>🎧 Noxely — serveur</h1>
<span id="status" class="pill">…</span>
<span class="grow" style="flex:1"></span>
<input type="password" id="token" placeholder="Jeton" style="width:190px">
<button class="ghost" onclick="saveToken()">Enregistrer</button>
</header>
<main>
<div class="tabs">
<button onclick="show('search')" aria-selected="true" id="t-search">🔎 Rechercher</button>
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</button>
<button onclick="show('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
</div>
<section id="p-search">
<div class="row">
<select id="catalog"></select>
<input type="text" id="q" placeholder="Titre, artiste, album…" onkeydown="if(event.key==='Enter')doSearch()">
<button class="go" onclick="doSearch()">Chercher</button>
</div>
<div id="results"></div>
</section>
<section id="p-watch" hidden>
<div class="row">
<input type="text" id="watchQuery" placeholder="Morceau à guetter sur toutes les stations"
onkeydown="if(event.key==='Enter')addWatch()">
<button class="go" onclick="addWatch()">Guetter</button>
<button class="ghost" onclick="sweep()">Balayer maintenant</button>
</div>
<div id="watches"></div>
</section>
<section id="p-queue" hidden><div id="queue"></div></section>
<section id="p-library" hidden><div id="library"></div></section>
</main>
<script>
const $ = (id) => document.getElementById(id);
let token = localStorage.getItem('noxely-token') || '';
$('token').value = token;
function saveToken() {
token = $('token').value.trim();
localStorage.setItem('noxely-token', token);
// Consultée depuis la machine du serveur, la page se remplit seule.
(async () => {
if (!token) {
try {
const { token: found } = await fetch('/api/bootstrap').then((r) => r.ok ? r.json() : {});
if (found) { token = found; $('token').value = found; localStorage.setItem('noxely-token', found); }
} catch {}
}
refreshAll();
})();
}
async function api(path, options = {}) {
const response = await fetch('/api' + path, {
...options,
headers: { 'X-Noxely-Token': token, 'Content-Type': 'application/json', ...(options.headers || {}) },
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).detail || response.status);
return response.json();
}
function show(name) {
for (const tab of ['search', 'watch', 'queue', 'library']) {
$('p-' + tab).hidden = tab !== name;
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
}
if (name === 'queue') loadQueue();
if (name === 'library') loadLibrary();
if (name === 'watch') loadWatches();
}
async function refreshAll() {
try {
const health = await fetch('/api/health').then((r) => r.json());
$('status').textContent = health.name + ' ' + health.version;
$('status').className = 'pill ok';
} catch { $('status').textContent = 'injoignable'; $('status').className = 'pill warn'; }
try {
const { catalogs } = await api('/catalogs');
$('catalog').innerHTML = Object.entries(catalogs)
.map(([id, label]) => `<option value="${id}">${label}</option>`).join('');
} catch {}
}
async function doSearch() {
const q = $('q').value.trim(); if (!q) return;
$('results').innerHTML = '<div class="empty">Recherche…</div>';
try {
const { results } = await api(`/search?catalog=${$('catalog').value}&q=${encodeURIComponent(q)}`);
$('results').innerHTML = results.length ? results.map((r, i) => `
<div class="card">
<div class="grow"><b>${esc(r.title)}</b><small>${
r.catalog ? '<span class="pill">' + esc(r.catalog) + '</span> ' : ''}${
esc(r.artist)} · ${r.kind}${
r.size ? ' · ~' + (r.size / 1e6).toFixed(0) + ' Mo' : ''}</small></div>
<button class="ghost" onclick='openTracks(${i})'>Pistes</button>
</div>`).join('') : '<div class="empty">Aucun résultat</div>';
window._results = results;
} catch (e) { $('results').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function openTracks(index) {
const item = window._results[index];
$('results').innerHTML = '<div class="empty">Lecture des pistes…</div>';
try {
// En recherche « toutes sources », c'est le catalogue du résultat qui compte, pas celui du menu.
const catalog = item.catalog || $('catalog').value;
const { tracks } = await api(`/tracks?catalog=${catalog}&id=${
encodeURIComponent(item.id)}&title=${encodeURIComponent(item.title)}&kind=${item.kind}`);
window._catalog = catalog;
window._tracks = tracks;
const total = tracks.reduce((sum, t) => sum + (t.size || 0), 0);
$('results').innerHTML = `
<div class="row"><button class="ghost" onclick="doSearch()">← Résultats</button>
<button class="go" onclick="queueAll()">Tout mettre en file${
total ? ' (' + (total / 1e6).toFixed(0) + ' Mo)' : ''}</button></div>` +
tracks.map((t, i) => `
<div class="card">
<div class="grow"><b>${esc(t.title)}</b><small>${
t.size ? (t.size / 1e6).toFixed(1) + ' Mo' : 'taille inconnue'}</small></div>
<button class="ghost" onclick="queueOne(${i})">Mettre en file</button>
</div>`).join('');
} catch (e) { $('results').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
const queueTracks = async (tracks) => {
const { queued } = await api('/queue', { method: 'POST',
body: JSON.stringify({ tracks, catalog: window._catalog || $('catalog').value }) });
alert(queued + ' piste(s) en file de téléchargement');
};
const queueOne = (i) => queueTracks([window._tracks[i]]);
const queueAll = () => queueTracks(window._tracks);
async function loadQueue() {
try {
const { items } = await api('/queue');
$('queue').innerHTML = items.length ? items.map((m) => `
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
<small>${esc(m.artist)} · ${m.state}${m.error ? ' — ' + esc(m.error) : ''}</small></div></div>`).join('')
: '<div class="empty">File vide</div>';
} catch (e) { $('queue').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function loadLibrary() {
try {
const { items } = await api('/library?since=0');
$('library').innerHTML = items.length ? items.map((m) => `
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
<small>${esc(m.artist)} · ${(m.size / 1e6).toFixed(1)} Mo · ${esc(m.source)}</small></div>
<button class="ghost" onclick="removeMedia('${m.id}')">Supprimer</button></div>`).join('')
: '<div class="empty">Rien de prêt pour le mobile</div>';
} catch (e) { $('library').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function removeMedia(id) {
if (!confirm('Supprimer définitivement ce média du serveur ?')) return;
await api('/media/' + id, { method: 'DELETE' });
loadLibrary();
}
async function loadWatches() {
try {
const { watches, recording, sweeps, last_sweep: last } = await api('/watches');
const scan = last && last.stations
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus`
: 'en attente d\'un guet';
$('watches').innerHTML =
`<div class="card"><div class="grow"><b>Balayage continu</b><small>${esc(scan)}</small></div>
<span class="pill ok">actif</span></div>` +
(recording ? `<div class="card"><div class="grow">
<b>Enregistrement en cours</b><small>${esc(recording)}</small></div></div>` : '') +
(watches.length ? watches.map((w) => `
<div class="card"><div class="grow"><b>${esc(w.query)}</b><small>${
w.found_at ? 'capté sur ' + esc(w.found_on || '') + ' — ' + esc(w.found_title || '')
: w.scans + ' balayage(s)'}</small></div>
<span class="pill ${w.found_at ? 'ok' : ''}">${w.found_at ? 'trouvé' : 'en attente'}</span>
<button class="ghost" onclick="removeWatch('${w.id}')">×</button></div>`).join('')
: '<div class="empty">Aucun guet</div>');
} catch (e) { $('watches').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function addWatch() {
const query = $('watchQuery').value.trim(); if (!query) return;
await api('/watches', { method: 'POST', body: JSON.stringify({ query }) });
$('watchQuery').value = '';
loadWatches();
}
const removeWatch = async (id) => { await api('/watches/' + id, { method: 'DELETE' }); loadWatches(); };
async function sweep() {
$('watches').innerHTML = '<div class="empty">Balayage des stations…</div>';
try {
const r = await api('/watches/sweep', { method: 'POST' });
alert(r.matched ? `Trouvé sur ${r.station} : ${r.matched}`
: `${r.stations} stations écoutées, ${r.titles} titres lus, rien de guetté`);
} catch (e) { alert(String(e)); }
loadWatches();
}
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Consultée depuis la machine du serveur, la page se remplit seule.
(async () => {
if (!token) {
try {
const { token: found } = await fetch('/api/bootstrap').then((r) => r.ok ? r.json() : {});
if (found) { token = found; $('token').value = found; localStorage.setItem('noxely-token', found); }
} catch {}
}
refreshAll();
})();
setInterval(() => {
if (!$('p-queue').hidden) loadQueue();
if (!$('p-watch').hidden) loadWatches();
}, 5000);
</script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
"""Tâches de fond : file de téléchargement, guet radio et enregistrement.
C'est ici que se déplace la consommation qui pesait sur le téléphone : le serveur balaie les
stations et télécharge, le mobile ne fait plus que récupérer des fichiers prêts.
"""
from __future__ import annotations
import asyncio
import re
import time
from pathlib import Path
import httpx
from .config import Config
from .sources import radio
from .storage import Storage
UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"}
def safe_name(text: str, extension: str) -> str:
cleaned = re.sub(r"[^\w \-.]", "", text).strip().replace(" ", "_")[:80] or "media"
return f"{cleaned}.{extension}"
class Downloader:
"""Vide la file des médias en attente, un à la fois."""
def __init__(self, config: Config, storage: Storage) -> None:
self.config = config
self.storage = storage
self.config.media_dir.mkdir(parents=True, exist_ok=True)
async def run_forever(self) -> None:
while True:
try:
await self.drain()
except Exception as error: # une panne ne doit pas arrêter la boucle
print(f"[noxely] téléchargement : {error}")
await asyncio.sleep(10)
async def drain(self) -> None:
for item in self.storage.pending_downloads():
await self.fetch(item.id, item.remote_url, item.filename)
async def fetch(self, media_id: str, url: str, filename: str) -> None:
target = self.config.media_dir / filename
partial = target.with_suffix(target.suffix + ".part")
self.storage.update_media(media_id, state="downloading", error=None)
try:
async with httpx.AsyncClient(headers=UA, timeout=httpx.Timeout(120.0),
follow_redirects=True) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
with partial.open("wb") as handle:
async for chunk in response.aiter_bytes(65536):
handle.write(chunk)
partial.replace(target)
self.storage.update_media(media_id, state="ready", size=target.stat().st_size)
except Exception as error:
partial.unlink(missing_ok=True)
self.storage.update_media(media_id, state="failed", error=str(error)[:200])
class Watcher:
"""Balaie les stations à la recherche des morceaux guettés, et les enregistre."""
def __init__(self, config: Config, storage: Storage) -> None:
self.config = config
self.storage = storage
self.recording: str | None = None
#: Dernier tour de balayage, pour que l'interface montre que ça tourne.
self.last_sweep: dict = {}
self.sweeps = 0
async def run_forever(self) -> None:
"""Balaie en continu : un tour enchaîne sur le suivant sans attendre.
Une sonde ne lit que le premier bloc de métadonnées puis referme — quelques dizaines de
kilo-octets par station. C'est ce qui rend le balayage permanent tenable, là où garder
quarante flux ouverts consommerait plusieurs mégabits par seconde en continu.
"""
while True:
try:
if self.storage.pending_watches():
self.last_sweep = await self.sweep()
self.sweeps += 1
else:
# Rien à guetter : inutile de solliciter l'annuaire.
await asyncio.sleep(30)
continue
except Exception as error:
print(f"[noxely] guet : {error}")
await asyncio.sleep(30)
continue
await asyncio.sleep(self.config.watch_interval)
async def sweep(self) -> dict:
pending = self.storage.pending_watches()
if not pending:
return {"stations": 0, "titles": 0, "at": _now(), "matched": None}
found = await radio.stations(limit=self.config.watch_stations)
titles = 0
for group in _chunks(found, self.config.watch_concurrency):
heard = await asyncio.gather(*(radio.now_playing(s["url"]) for s in group))
for station, title in zip(group, heard):
if not title:
continue
titles += 1
hit = next((w for w in pending if w["query"].lower() in title.lower()), None)
if not hit:
continue
if self.recording is None:
asyncio.create_task(self.record(station, title, hit["query"]))
self.storage.mark_watch_found(hit["id"], station["name"], title)
self.storage.mark_watches_scanned([w["id"] for w in pending])
return {"stations": len(found), "titles": titles, "at": _now(),
"matched": title, "station": station["name"]}
self.storage.mark_watches_scanned([w["id"] for w in pending])
return {"stations": len(found), "titles": titles, "at": _now(), "matched": None}
async def record(self, station: dict, title: str, trigger: str) -> None:
"""Enregistre jusqu'à ce que la station annonce un autre morceau."""
self.recording = station["name"]
extension = "aac" if station["codec"].upper().startswith("AAC") else "mp3"
filename = safe_name(title, extension)
media_id = self.storage.add_media(
title=title, artist=station["name"], source="radio", origin=station["name"],
filename=filename, remote_url=None,
mime="audio/aac" if extension == "aac" else "audio/mpeg", state="downloading",
)
target = self.config.media_dir / filename
headers = {**UA, "Icy-MetaData": "1"}
written = 0
try:
async with httpx.AsyncClient(headers=headers, timeout=httpx.Timeout(60.0)) as client:
async with client.stream("GET", station["url"]) as response:
response.raise_for_status()
interval = int(response.headers.get("icy-metaint") or 0)
with target.open("wb") as handle:
written = await self._copy(response, handle, interval, title)
self.storage.update_media(media_id, state="ready", size=written)
except Exception as error:
self.storage.update_media(media_id, state="failed", error=str(error)[:200])
finally:
self.recording = None
async def _copy(self, response, handle, interval: int, wanted: str) -> int:
"""Retire les blocs ICY du flux : laissés dedans, ils rendraient le fichier illisible."""
if interval <= 0:
total = 0
async for chunk in response.aiter_bytes(32768):
handle.write(chunk)
total += len(chunk)
if total > 40_000_000: # garde-fou : 40 Mo sans annonce de fin
break
return total
buffer = bytearray()
total = 0
async for chunk in response.aiter_bytes(32768):
buffer.extend(chunk)
while len(buffer) >= interval + 1:
length = buffer[interval] * 16
if len(buffer) < interval + 1 + length:
break
handle.write(bytes(buffer[:interval]))
total += interval
meta = bytes(buffer[interval + 1:interval + 1 + length]).decode("utf-8", "replace")
del buffer[:interval + 1 + length]
announced = radio.STREAM_TITLE.search(meta)
if announced and announced.group(1).strip() and announced.group(1).strip() != wanted:
return total # le morceau est terminé
if total > 60_000_000:
return total
return total
def _now() -> int:
return int(time.time() * 1000)
def _chunks(items: list, size: int):
for index in range(0, len(items), size):
yield items[index:index + size]
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
httpx>=0.27.0
+1 -1
View File
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "Cachette" rootProject.name = "Noxely"
include(":app") include(":app")