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

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

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

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

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

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

Enfin, un catalogue Underground restreint aux collections netlabels et
freemusicarchive, où tout est publié sous licence libre par les artistes :
le fonds indépendant que la recherche généraliste noyait.
2026-09-13 12:56:16 +02:00
tuxgyver b96354c40f Les enregistrements radio portent le nom du morceau capté (1.4.0)
Un enregistrement s'appelait « Station — date », ce qui oblige à rouvrir le
fichier pour savoir ce qu'il contient. Le flux ICY annonce le morceau diffusé :
le service le demande, le premier titre capté nomme l'enregistrement, la station
devient le sous-titre, et le morceau en cours s'affiche pendant la capture.

Ces octets de métadonnées ne sont pas de l'audio et sont retirés avant écriture ;
laissés dans le fichier, ils le rendraient illisible. La valeur du champ se lit
jusqu'à sa fin, et non jusqu'à la première apostrophe, sans quoi « Ain't That a
Kick in the Head » se réduirait à « Ain ».

L'onglet Hors ligne ignorait par ailleurs les enregistrements : n'ayant aucun
état de téléchargement, ils échappaient à son filtre alors qu'ils sont, par
nature, ce qu'il y a de plus disponible sans réseau. Ils y figurent désormais,
la taille annoncée les additionne au cache, et « Tout libérer » dit clairement
qu'il les épargne.
2026-09-13 12:15:46 +02:00
tuxgyver 4017c71ca7 Radio en direct et à l'enregistrement, téléchargements qui se relancent seuls (1.3.0)
Un téléchargement qui échoue restait échoué, attendant un geste. Il repart
maintenant de lui-même après cinq, quinze puis quarante-cinq secondes.
Vérifié contre un serveur qui refuse deux fois avant d'accepter : trois
requêtes, et le fichier arrive sans intervention.

Cela ne recouvre pas la coupure réseau, et c'est voulu : sans réseau, Media3
met déjà le téléchargement en attente et le reprend au retour. Les deux
mécanismes se complètent au lieu de se concurrencer.

La radio manquait. L'annuaire Radio Browser fournit les stations les plus
écoutées, par genre ou par nom ; chacune s'écoute en direct ou s'enregistre.
Un direct n'ayant pas de fin, DownloadManager ne pouvait pas s'en charger :
un service de premier plan copie le flux jusqu'à l'arrêt demandé, puis verse
le fichier dans la bibliothèque, déjà hors connexion. Les flux HLS sont
écartés de la liste, ne se laissant pas enregistrer par copie d'octets.

Le repli vers une autre source en cas d'échec n'a pas été retenu : rien ne
garantit qu'un même titre trouvé sur deux catalogues soit le même
enregistrement.
2026-09-13 12:00:46 +02:00
tuxgyver 2dabfdbc84 Choix des pistes, écoute en ligne, playlists et source Wikimedia (1.2.0)
Un album ou un livre se téléchargeait en aveugle : l'application retenait une
piste arbitraire sans jamais dire laquelle ni ce qu'elle pesait. La fiche d'un
résultat liste désormais toutes ses pistes avec durée et taille, le total est
annoncé, et le bouton chiffre ce qu'on s'apprête à emporter. Chaque piste
s'essaie d'abord en streaming, sans rien mettre en cache.

Les playlists manquaient pour organiser ce qu'on garde : listes nommées,
ajout par appui long, lecture enchaînée. Les contrôles se complètent d'un
arrêt et de sauts de quinze secondes.

Le filtre de langue de LibriVox ne pouvait pas marcher côté serveur : l'API
accepte le paramètre puis l'ignore, « language=French » seul renvoyant des
livres anglais. Le tri se fait sur le champ de chaque livre. Les délais réseau
suivent la latence réellement mesurée des services, LibriVox répondant en
quatorze à seize secondes quelle que soit la taille demandée.

Wikimedia Commons rejoint les catalogues libres. Openverse et ccMixter ont été
essayés puis écartés : le premier refuse l'accès anonyme, le second exige un
en-tête Referer que Media3 ne sait pas poser par téléchargement.
2026-09-13 09:44:56 +02:00
tuxgyver c1912c2056 Recherche LibriVox réparée, téléchargements Archive fiabilisés, mises à jour intégrées (1.1.0)
Deux bugs signalés à l'usage, et la distribution hors magasin qui manquait.

LibriVox ne renvoyait jamais rien : l'API cherche les titres en correspondance
exacte sauf préfixe « ^ », et les auteurs sur le seul nom de famille. Les trois
critères sont interrogés en parallèle et dédoublonnés, et un 404 « aucun
résultat » n'est plus confondu avec une panne.

Les téléchargements Internet Archive échouaient pour trois raisons distinctes :
éléments en accès restreint répondant 401, filtre limité aux seuls .mp3/.mp4, et
choix du premier fichier plutôt que du plus petit dérivé. La taille de chaque
résultat est désormais affichée avant sélection, ce qui distingue une chanson
d'une émission de 500 Mo.

L'application étant distribuée hors magasin, elle surveille elle-même les
releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation, qui
reste un geste de l'utilisateur comme Android l'impose.
2026-09-11 20:57:58 +02:00
76 changed files with 4327 additions and 695 deletions

No files matched your search

+4 -1
View File
@@ -10,4 +10,7 @@ captures/
keystore.properties
*.jks
.gitea-token
Cachette-*.apk
# Motif large : un motif au nom du projet a laissé passer 95 Mo lors du renommage.
*.apk
update-token.properties
app/src/main/assets/CHANGELOG.md
+150 -1
View File
@@ -1,10 +1,159 @@
# Changelog
Toutes les évolutions notables de Cachette sont documentées ici.
Toutes les évolutions notables de Noxely sont documentées ici.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
et le versionnage [SemVer](https://semver.org/lang/fr/).
## [1.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
### Ajouté
- Titrage automatique des enregistrements radio. Le service demande désormais les métadonnées
ICY, ce qui fait annoncer par la station le morceau diffusé : l'enregistrement prend son nom,
la station passe en sous-titre, et le titre en cours s'affiche pendant la capture comme dans
la notification. Ces octets de métadonnées sont retirés avant écriture — laissés dans le
fichier, ils le rendraient illisible. Les stations qui n'en diffusent pas gardent le nom
« Station — date ».
### Corrigé
- Les enregistrements radio n'apparaissaient pas dans l'onglet « Hors ligne », qui ne listait que
ce qui porte un état de téléchargement. L'écran annonçait donc un nombre d'éléments disponibles
sans réseau en omettant précisément ceux qui l'étaient déjà. La taille affichée additionne
maintenant le cache et les enregistrements, et « Tout libérer » précise qu'il ne les touche pas.
- Les titres comportant une apostrophe étaient tronqués à la première rencontrée : « Ain't That a
Kick in the Head » devenait « Ain ». La valeur est lue jusqu'à la fin du champ ICY.
## [1.3.0] — 2026-09-13
### Ajouté
- **Radio.** Les stations les plus écoutées via l'annuaire communautaire Radio Browser, avec
filtres par genre et recherche par nom. Chaque station s'écoute en direct, ou s'enregistre :
un service de premier plan copie le flux dans un fichier jusqu'à l'arrêt demandé, et le verse
dans la bibliothèque — déjà hors connexion, sans rien à télécharger ensuite.
- Relance automatique des téléchargements en échec, après une pause croissante de 5, 15 puis
45 secondes. Une relance manuelle repart d'un compteur neuf.
### Notes
- Les flux HLS sont écartés de la liste des stations : ils se lisent, mais ne s'enregistrent pas
par copie d'octets, et un bouton qui échouerait vaudrait moins que pas de bouton.
- La relance et la reprise sur coupure réseau sont deux mécanismes distincts, et c'est voulu :
sans réseau, Media3 met le téléchargement en attente et le reprend de lui-même ; la relance,
elle, ne traite que les vrais échecs serveur.
- Le repli automatique vers une autre source n'a pas été retenu : rien ne garantit qu'un même
titre trouvé sur deux catalogues soit le même enregistrement, et l'application téléchargerait
silencieusement autre chose que ce qui a été choisi.
## [1.2.0] — 2026-09-13
### Ajouté
- Écran de choix des pistes. Un album ou un livre n'est plus une boîte noire : ses pistes sont
listées avec leur durée et leur taille, le total est annoncé, et le bouton dit exactement ce
qu'on s'apprête à télécharger (« Garder 4,2 Mo »). Les variantes d'un même morceau sont
regroupées — autant de lignes que d'œuvres, pas que de formats.
- Écoute en ligne avant téléchargement : un appui sur une piste la joue en streaming sans rien
mettre en cache.
- Playlists : listes nommées, ajout par appui long sur un média ou depuis l'écran de lecture,
lecture enchaînée, suppression piste par piste.
- Contrôles de lecture complétés : arrêt, saut de 15 secondes en avant et en arrière.
- Filtrage par langue des catalogues (français, anglais, allemand, espagnol, italien, néerlandais).
- Nouvelle source libre : **Wikimedia Commons**, pour les enregistrements classiques et les
archives sonores du domaine public.
### Corrigé
- Le filtre de langue de LibriVox ne pouvait pas fonctionner côté serveur : l'API accepte le
paramètre `language` puis l'ignore (`language=French` seul renvoie des livres anglais). Le tri
se fait désormais sur le champ `language` de chaque livre.
- Délais réseau ajustés d'après la latence mesurée des services : LibriVox répond en 14 à
16 secondes quelle que soit la taille demandée, ce qui faisait expirer les requêtes parallèles.
### Notes
- Deux catalogues ont été évalués puis écartés sur mesure plutôt que sur principe : **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.
## [1.1.0] — 2026-09-11
### Ajouté
- Mise à jour intégrée : Noxely surveille les releases du dépôt, télécharge l'APK en Wi-Fi
et propose l'installation. Vérification quotidienne en tâche de fond, et à la demande depuis
l'écran Sources, qui affiche aussi la version installée.
- La taille approximative de chaque résultat Internet Archive est affichée avant sélection : un
élément de 500 Mo ne se laisse plus confondre avec une chanson.
### Corrigé
- Recherche LibriVox qui ne renvoyait rien. L'API cherche les titres en correspondance exacte
sauf si la requête est préfixée par `^`, et les auteurs sur le seul nom de famille : « Jules
Verne » n'atteignait donc ni l'un ni l'autre. Les trois critères sont désormais interrogés en
parallèle et dédoublonnés, et un « aucun résultat » (HTTP 404) n'est plus traité comme une panne.
- Téléchargements Internet Archive qui échouaient ou n'aboutissaient pas. Trois causes distinctes :
les éléments en accès restreint répondaient 401 et sont maintenant écartés dès la recherche ;
seuls les fichiers `.mp3` et `.mp4` étaient acceptés, ce qui excluait des éléments ne proposant
que du `.m4a` ou de l'`.ogg` ; et le fichier retenu était le premier trouvé, souvent l'original
non compressé — c'est désormais le plus petit dérivé, soit 9 Mo au lieu de 30 sur un cas mesuré.
## [1.0.0] — 2026-09-11
### Ajouté
+1 -1
View File
@@ -15,7 +15,7 @@ DEBUG_APK := app/build/outputs/apk/debug/app-debug.apk
# app-release-unsigned.apk) : on liste le répertoire au lieu de deviner.
RELEASE_DIR := app/build/outputs/apk/release
SIGNED_APK := $(RELEASE_DIR)/app-release.apk
OUTPUT_APK := Cachette-$(VERSION).apk
OUTPUT_APK := Noxely-$(VERSION).apk
# --- Publication -----------------------------------------------------------
# Déléguée au script de BorisSkill, qui sait déjà tout faire : résolution du
+89 -8
View File
@@ -1,4 +1,4 @@
# 🎧 Cachette
# 🎧 Noxely
> Mettez la musique et les vidéos **de côté** pendant que vous avez du réseau. Écoutez-les quand vous n'en avez plus.
@@ -6,10 +6,10 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org)
[![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose)
[![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3)
[![Version](https://img.shields.io/badge/Version-1.0.0-6B4EFF)](VERSION)
[![Version](https://img.shields.io/badge/Version-1.6.0-6B4EFF)](VERSION)
[![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche)
Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
Noxely est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
forfaits épuisés. On choisit ce qu'on emporte, l'application le télécharge, et tout reste lisible
sans la moindre connexion.
@@ -30,13 +30,24 @@ sans la moindre connexion.
| 🎵 **Audio et vidéo** | Un seul lecteur, une seule file d'attente, la vidéo bascule sur une surface dédiée |
| 📥 **Mise en cache explicite** | Rien n'est téléchargé sans un geste : vous décidez quoi garder |
| 🔍 **Parcours des sources** | On entre dans une source, on coche ce qu'on veut, on télécharge en lot |
| 🎧 **Écoute avant téléchargement** | Chaque piste s'essaie en ligne d'un appui, sans rien mettre en cache |
| 🧾 **Choix piste par piste** | Un album ou un livre affiche ses pistes avec leur taille : on n'emporte que ce qu'on veut |
| 🌍 **Filtrage par langue** | Français, anglais, allemand, espagnol, italien, néerlandais |
| 📋 **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 |
| 📻 **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é |
| ⏯️ **Contrôles complets** | Lecture, pause, arrêt, piste précédente et suivante, ±15 s |
| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti |
| 🔁 **Reprise automatique** | Un téléchargement coupé repart tout seul via WorkManager |
| 🔁 **Reprise automatique** | Réseau coupé : mise en attente et reprise. Échec serveur : relance seule après 5 s, 15 s, 45 s |
| 📻 **Radio** | Les stations les plus écoutées, en direct — et enregistrables d'un appui |
| 🏷️ **Titrage automatique** | L'enregistrement prend le nom du morceau annoncé par la station |
| ℹ️ **Page « À propos »** | Fonctions, garanties, pile technique, journal et auteur |
| 👀 **Titre en cours** | Ce que diffuse chaque station, lu dans son flux |
| ⏰ **Guet d'un titre** | L'enregistrement se déclenche quand le morceau voulu passe, et s'arrête à sa fin |
| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation |
---
@@ -48,9 +59,20 @@ sans la moindre connexion.
| 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo |
| 📻 Podcast RSS | ✅ | Flux standard avec `<enclosure>` |
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
| 📚 LibriVox | ✅ | Livres audio du domaine public |
| 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue |
| 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres |
| 📻 Radio | ✅ | Annuaire Radio Browser, direct et enregistrement (flux HLS exclus) |
| 🎧 Underground | ✅ | Netlabels et Free Music Archive : ~94 000 morceaux d'artistes indépendants |
### ⚖️ Ce que Cachette ne fait pas — et pourquoi
### 🔎 Catalogues évalués puis écartés
| Candidat | Pourquoi non |
|---|---|
| **Openverse** | A cessé de servir l'accès anonyme : **401 sur huit requêtes sur huit**, et jusqu'à 21 s de latence quand il répondait encore. Exige désormais une inscription. |
| **ccMixter** | La recherche fonctionne, mais les fichiers ne sont servis qu'avec un en-tête `Referer` — que Media3 ne permet pas de poser par téléchargement. |
| **Radio Browser** | Flux radio continus : rien à mettre en cache. |
### ⚖️ Ce que Noxely ne fait pas — et pourquoi
| Service | Pourquoi |
|---|---|
@@ -162,6 +184,7 @@ Gradle, le tag git et les notes de release suivront seuls.
| `INTERNET` | Télécharger ce que vous avez choisi de garder |
| `POST_NOTIFICATIONS` | Commandes de lecture et progression des téléchargements |
| `FOREGROUND_SERVICE_*` | Continuer la lecture et les téléchargements écran éteint |
| `REQUEST_INSTALL_PACKAGES` | Proposer l'installation d'une mise à jour téléchargée |
Aucune donnée ne quitte l'appareil : pas de compte, pas de télémétrie, pas de sauvegarde cloud
(`dataExtractionRules` exclut explicitement le contenu mis en cache). Les seules connexions
@@ -172,18 +195,76 @@ 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
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)
│
├─ version plus récente ? ──► téléchargement de l'APK
│ │
└─ sinon : rien ▼
notification « prête à installer »
│
installation = geste utilisateur
```
L'installation reste un geste explicite : Android l'impose à toute application hors magasin.
L'écran **Sources** porte la même vérification à la demande, et affiche la version installée.
Le dépôt étant privé, la lecture des releases demande un jeton. Il est lu depuis
`update-token.properties` (ignoré par git) et injecté dans `BuildConfig` à la compilation :
```properties
giteaUpdateToken=…
```
> ⚠️ Un jeton embarqué dans un APK est extractible. Celui-ci est volontairement limité à la portée
> `read:repository` et révocable depuis Gitea. Il donne un accès en lecture aux dépôts du compte —
> ne pas lui accorder davantage.
---
## 🗂️ Structure
```
app/src/main/java/fr/tuxgyver/cachette/
├── CachetteApp.kt Application + conteneur de dépendances (assemblé à la main)
├── NoxelyApp.kt Application + conteneur de dépendances (assemblé à la main)
├── MainActivity.kt
├── model/ MediaEntry, MediaKind, SourceKind
├── library/ MediaStore, persistance des imports, bibliothèque unifiée
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
├── offline/ Cache Media3, gestionnaire et service de téléchargement
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
└── ui/ Compose — thème, bibliothèque, hors ligne, sources, parcours, lecteur
├── radio/ Annuaire des stations, service d'enregistrement du direct
├── update/ Vérification des releases, téléchargement, notification
└── ui/ Compose — thème, bibliothèque, hors ligne, playlists, sources,
parcours des catalogues, choix des pistes, lecteur
```
---
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.6.0
+22 -2
View File
@@ -8,11 +8,11 @@ plugins {
}
android {
namespace = "fr.tuxgyver.cachette"
namespace = "fr.tuxgyver.noxely"
compileSdk = 36
defaultConfig {
applicationId = "fr.tuxgyver.cachette"
applicationId = "fr.tuxgyver.noxely"
minSdk = 26
targetSdk = 35
@@ -22,6 +22,18 @@ android {
val (major, minor, patch) = appVersion.split(".").map(String::toInt)
versionName = appVersion
versionCode = major * 10000 + minor * 100 + patch
// Le dépôt de publication est privé : la vérification des mises à jour a besoin d'un
// jeton en lecture seule, lu depuis un fichier local ignoré par git, jamais codé en dur.
val updateTokenFile = rootProject.file("update-token.properties")
val updateToken = if (updateTokenFile.exists()) {
Properties().apply { updateTokenFile.inputStream().use { load(it) } }
.getProperty("giteaUpdateToken", "")
} else {
""
}
buildConfigField("String", "GITEA_UPDATE_TOKEN", "\"$updateToken\"")
buildConfigField("String", "UPDATE_API", "\"https://git.tuxtech.fr/api/v1/repos/tuxgyver/Noxely\"")
}
buildTypes {
@@ -55,6 +67,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
packaging {
@@ -64,6 +77,13 @@ android {
}
}
/** Le journal affiché dans « À propos » est celui du dépôt, pas une copie qui dériverait. */
val copyChangelog = tasks.register<Copy>("copyChangelogToAssets") {
from(rootProject.file("CHANGELOG.md"))
into(layout.projectDirectory.dir("src/main/assets"))
}
tasks.named("preBuild") { dependsOn(copyChangelog) }
dependencies {
implementation(platform("androidx.compose:compose-bom:2025.10.00"))
implementation("androidx.compose.ui:ui")
+20 -4
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -17,7 +18,7 @@
android:maxSdkVersion="32" />
<application
android:name=".CachetteApp"
android:name=".NoxelyApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
@@ -25,13 +26,13 @@
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Cachette">
android:theme="@style/Theme.Noxely">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Cachette"
android:theme="@style/Theme.Noxely"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -49,7 +50,7 @@
</service>
<service
android:name=".offline.CachetteDownloadService"
android:name=".offline.NoxelyDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync">
<intent-filter>
@@ -58,5 +59,20 @@
</intent-filter>
</service>
<service
android:name=".radio.RecordingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -1,57 +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.offline.DownloadTracker
import fr.tuxgyver.cachette.offline.OfflineModule
import fr.tuxgyver.cachette.playback.PlayerController
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
import fr.tuxgyver.cachette.sources.Catalog
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
import fr.tuxgyver.cachette.sources.RssImporter
import fr.tuxgyver.cachette.sources.UrlImporter
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 urlImporter = UrlImporter()
val rssImporter = RssImporter()
val catalogs: Map<CatalogId, Catalog> = listOf(
ArchiveOrgCatalog(),
LibriVoxCatalog(rssImporter),
).associateBy { it.id }
val player = PlayerController(context, scope) { id -> library.entry(id) }
}
class CachetteApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
@@ -1,104 +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
private val json = Json { ignoreUnknownKeys = true }
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
runCatching {
val q = URLEncoder.encode("($query) AND mediatype:(audio OR movies)", "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" +
"&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",
)
}
}
}
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
runCatching {
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
val files = metadata["files"] as? JsonArray ?: JsonArray(emptyList())
val wantsVideo = item.hint == "Vidéo"
val playable = files.mapNotNull { it as? JsonObject }
.firstOrNull { file ->
val name = file.text("name").orEmpty().lowercase()
if (wantsVideo) name.endsWith(".mp4") else name.endsWith(".mp3")
}
?: throw IllegalStateException("Aucun fichier lisible pour « ${item.title} »")
val name = playable.text("name")!!
val seconds = playable.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
listOf(
MediaEntry(
id = stableId("ia:", "${item.id}/$name"),
source = SourceKind.URL,
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
title = item.title,
subtitle = item.subtitle,
uri = "https://archive.org/download/${item.id}/${URLEncoder.encode(name, "UTF-8").replace("+", "%20")}",
artworkUri = item.artworkUri,
durationMs = seconds * 1000,
addedAt = System.currentTimeMillis(),
)
)
}
}
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()
}
}
}
/** 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,45 +0,0 @@
package fr.tuxgyver.cachette.sources
import fr.tuxgyver.cachette.model.MediaEntry
/** Un résultat de recherche dans un catalogue, avant qu'on sache quel fichier exact il désigne. */
data class CatalogItem(
val id: String,
val title: String,
val subtitle: String,
val artworkUri: String? = null,
val hint: String = "",
)
/** Catalogue public interrogeable sans compte ni clé d'API. */
interface Catalog {
val id: CatalogId
suspend fun search(query: String): Result<List<CatalogItem>>
/**
* Transforme un résultat en entrées jouables. Un livre audio en rend plusieurs, un morceau une
* seule : la résolution est faite ici, à la sélection, et pas pendant la recherche.
*/
suspend fun resolve(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",
),
}
@@ -1,66 +0,0 @@
package fr.tuxgyver.cachette.sources
import fr.tuxgyver.cachette.model.MediaEntry
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 java.net.URLEncoder
/**
* Recherche dans LibriVox.
*
* Chaque livre publie un flux RSS : la résolution réutilise donc 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
private val json = Json { ignoreUnknownKeys = true }
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
runCatching {
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25" +
"&title=${URLEncoder.encode(query, "UTF-8")}"
val connection = openConnection(url)
val body = try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
}
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray
?: throw IllegalStateException("Aucun livre trouvé")
books.mapNotNull { element ->
val book = element as? JsonObject ?: 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 = "Livre audio",
)
}
}
}
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
rss.import(item.id).map { it.episodes }
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,250 +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.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.Search
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.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 kotlinx.coroutines.launch
/**
* Recherche dans un catalogue libre de droits, puis ajout de la sélection à la bibliothèque.
*
* L'ajout déclenche aussi la mise en cache : on ne parcourt pas un catalogue pour regarder
* passer les titres, mais pour les emporter.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CatalogBrowseScreen(
container: AppContainer,
catalogId: CatalogId,
onBack: () -> Unit,
onMessage: (String) -> Unit,
contentPadding: PaddingValues,
) {
val catalog = container.catalogs.getValue(catalogId)
val scope = rememberCoroutineScope()
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
var selection by remember { mutableStateOf(emptySet<String>()) }
var searching by remember { mutableStateOf(false) }
var adding 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).fold(
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
onFailure = { error = it.message ?: "Recherche impossible" },
)
searching = false
}
}
LaunchedEffect(catalogId) { runSearch() }
// 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(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),
)
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,
selected = item.id in selection,
onToggle = {
selection = if (item.id in selection) selection - item.id
else selection + item.id
},
)
}
item { Spacer(Modifier.size(88.dp)) }
}
}
}
SelectionBar(
count = selection.size,
actionLabel = "Ajouter et garder",
busy = adding,
onClear = { selection = emptySet() },
onConfirm = {
adding = true
scope.launch {
val chosen = results.filter { it.id in selection }
var added = 0
var failed = 0
chosen.forEach { item ->
catalog.resolve(item).fold(
onSuccess = { entries ->
container.imported.add(entries)
entries.forEach(container.downloads::download)
added += entries.size
},
onFailure = { failed++ },
)
}
adding = false
selection = emptySet()
onMessage(
buildString {
append("$added élément(s) ajoutés et mis en cache")
if (failed > 0) append(" · $failed indisponible(s)")
}
)
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(contentPadding),
)
}
}
@Composable
private fun CatalogRow(
item: CatalogItem,
selected: Boolean,
onToggle: () -> Unit,
) {
Surface(
color = if (selected) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp),
) {
Artwork(
uri = item.artworkUri,
title = item.title,
kind = if (item.hint == "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 = listOf(item.subtitle, item.hint).filter { it.isNotBlank() }.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Checkbox(checked = selected, onCheckedChange = { onToggle() })
}
}
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette
package fr.tuxgyver.noxely
import android.Manifest
import android.content.pm.PackageManager
@@ -9,8 +9,8 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import fr.tuxgyver.cachette.ui.CachetteRoot
import fr.tuxgyver.cachette.ui.theme.CachetteTheme
import fr.tuxgyver.noxely.ui.NoxelyRoot
import fr.tuxgyver.noxely.ui.theme.NoxelyTheme
class MainActivity : ComponentActivity() {
@@ -21,20 +21,20 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val container = (application as CachetteApp).container
val container = (application as NoxelyApp).container
container.player.connect()
askForNotificationsIfNeeded()
setContent {
CachetteTheme {
CachetteRoot(container)
NoxelyTheme {
NoxelyRoot(container)
}
}
}
override fun onDestroy() {
if (isFinishing) {
(application as CachetteApp).container.player.release()
(application as NoxelyApp).container.player.release()
}
super.onDestroy()
}
@@ -0,0 +1,80 @@
package fr.tuxgyver.noxely
import android.app.Application
import android.content.Context
import fr.tuxgyver.noxely.library.ImportedStore
import fr.tuxgyver.noxely.library.LibraryRepository
import fr.tuxgyver.noxely.library.LocalMediaScanner
import fr.tuxgyver.noxely.library.PlaylistStore
import fr.tuxgyver.noxely.offline.DownloadTracker
import fr.tuxgyver.noxely.offline.OfflineModule
import fr.tuxgyver.noxely.playback.PlayerController
import fr.tuxgyver.noxely.radio.RadioDirectory
import fr.tuxgyver.noxely.sources.ArchiveOrgCatalog
import fr.tuxgyver.noxely.sources.Catalog
import fr.tuxgyver.noxely.sources.CatalogId
import fr.tuxgyver.noxely.sources.CatalogItem
import fr.tuxgyver.noxely.sources.WikimediaCatalog
import fr.tuxgyver.noxely.sources.LibriVoxCatalog
import fr.tuxgyver.noxely.sources.RssImporter
import fr.tuxgyver.noxely.sources.UrlImporter
import fr.tuxgyver.noxely.update.UpdateCheckWorker
import fr.tuxgyver.noxely.update.UpdateRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* 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(),
ArchiveOrgCatalog(
id = CatalogId.UNDERGROUND,
collections = listOf("netlabels", "freemusicarchive"),
),
LibriVoxCatalog(rssImporter),
WikimediaCatalog(),
).associateBy { it.id }
/**
* Résultat de catalogue en cours de consultation.
* Un identifiant de route ne suffirait pas à le reconstituer sans un appel réseau de plus.
*/
var openedCatalogItem: CatalogItem? = null
val player = PlayerController(context, scope) { id -> library.entry(id) }
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)
}
}
@@ -1,7 +1,7 @@
package fr.tuxgyver.cachette.library
package fr.tuxgyver.noxely.library
import android.content.Context
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -1,6 +1,6 @@
package fr.tuxgyver.cachette.library
package fr.tuxgyver.noxely.library
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -39,7 +39,18 @@ class LibraryRepository(
fun hasLocalPermission(): Boolean = scanner.hasPermission()
fun entry(id: String): MediaEntry? = entries.value.firstOrNull { it.id == id }
/**
* Titres écoutés en aperçu, pas encore ajoutés à la bibliothèque.
* Le service de lecture résout les URI par identifiant : sans eux, un aperçu ne démarrerait pas.
*/
private var previewing: List<MediaEntry> = emptyList()
fun preview(entries: List<MediaEntry>) {
previewing = entries
}
fun entry(id: String): MediaEntry? =
entries.value.firstOrNull { it.id == id } ?: previewing.firstOrNull { it.id == id }
suspend fun forget(id: String) = imported.remove(id)
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.library
package fr.tuxgyver.noxely.library
import android.content.ContentUris
import android.content.Context
@@ -8,9 +8,9 @@ import androidx.core.net.toUri
import android.os.Build
import android.provider.MediaStore
import androidx.core.content.ContextCompat
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -55,7 +55,7 @@ class LocalMediaScanner(private val context: Context) {
context.contentResolver.query(
collection,
projection,
"${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0",
audioSelection(),
null,
null,
)?.use { cursor ->
@@ -123,6 +123,22 @@ class LocalMediaScanner(private val context: Context) {
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 =
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
@@ -0,0 +1,91 @@
package fr.tuxgyver.noxely.library
import android.content.Context
import fr.tuxgyver.noxely.model.Playlist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.util.UUID
/** Persistance des playlists, sur le même principe que les médias importés. */
class PlaylistStore(context: Context, scope: CoroutineScope) {
private val file = File(context.filesDir, "playlists.json")
private val json = Json { ignoreUnknownKeys = true }
private val mutex = Mutex()
private val _playlists = MutableStateFlow<List<Playlist>>(emptyList())
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()
init {
scope.launch { _playlists.value = read() }
}
suspend fun create(name: String): Playlist = mutate { current ->
val playlist = Playlist(
id = UUID.randomUUID().toString(),
name = name.trim().ifBlank { "Sans titre" },
createdAt = System.currentTimeMillis(),
)
current + playlist
}.last()
suspend fun delete(id: String) {
mutate { current -> current.filterNot { it.id == id } }
}
suspend fun rename(id: String, name: String) {
mutate { current ->
current.map { if (it.id == id) it.copy(name = name.trim().ifBlank { it.name }) else it }
}
}
/** Ajoute sans doublon : une piste déjà présente n'est pas ajoutée une seconde fois. */
suspend fun add(playlistId: String, entryIds: List<String>) {
mutate { current ->
current.map { playlist ->
if (playlist.id != playlistId) playlist
else playlist.copy(entryIds = (playlist.entryIds + entryIds).distinct())
}
}
}
suspend fun remove(playlistId: String, entryId: String) {
mutate { current ->
current.map { playlist ->
if (playlist.id != playlistId) playlist
else playlist.copy(entryIds = playlist.entryIds - entryId)
}
}
}
fun playlist(id: String): Playlist? = _playlists.value.firstOrNull { it.id == id }
private suspend fun mutate(transform: (List<Playlist>) -> List<Playlist>): List<Playlist> =
mutex.withLock {
val updated = transform(_playlists.value)
_playlists.value = updated
write(updated)
updated
}
private suspend fun read(): List<Playlist> = withContext(Dispatchers.IO) {
if (!file.exists()) return@withContext emptyList()
runCatching { json.decodeFromString<List<Playlist>>(file.readText()) }.getOrElse { emptyList() }
}
private suspend fun write(playlists: List<Playlist>) = withContext(Dispatchers.IO) {
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(json.encodeToString(playlists))
tmp.renameTo(file)
}
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.model
package fr.tuxgyver.noxely.model
import kotlinx.serialization.Serializable
@@ -9,6 +9,7 @@ enum class SourceKind(val label: String) {
LOCAL("Appareil"),
URL("Lien direct"),
PODCAST("Podcast"),
RECORDING("Enregistrement"),
}
/**
@@ -28,7 +29,9 @@ data class MediaEntry(
val artworkUri: String? = null,
val durationMs: Long = 0L,
val addedAt: Long = 0L,
/** Taille du fichier quand la source l'annonce, pour choisir avant de télécharger. */
val sizeBytes: Long? = null,
) {
/** Un média local est déjà disponible hors connexion : il n'a rien à télécharger. */
val isRemote: Boolean get() = source != SourceKind.LOCAL
/** 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
}
@@ -0,0 +1,17 @@
package fr.tuxgyver.noxely.model
import kotlinx.serialization.Serializable
/**
* Une playlist ne contient que des identifiants.
*
* Les métadonnées appartiennent à la bibliothèque : les dupliquer ici les laisserait diverger
* dès qu'un média est renommé ou retiré.
*/
@Serializable
data class Playlist(
val id: String,
val name: String,
val entryIds: List<String> = emptyList(),
val createdAt: Long = 0L,
)
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.offline
package fr.tuxgyver.noxely.offline
import android.content.Context
import androidx.annotation.OptIn
@@ -8,7 +8,7 @@ import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -28,13 +28,15 @@ data class OfflineState(
val state: Int,
val percent: Float,
val bytesDownloaded: Long,
/** Une relance automatique est programmée : l'échec n'est pas définitif. */
val retrying: Boolean = false,
) {
val isComplete: Boolean get() = state == Download.STATE_COMPLETED
val isFailed: Boolean get() = state == Download.STATE_FAILED
val isFailed: Boolean get() = state == Download.STATE_FAILED && !retrying
val isPending: Boolean
get() = state == Download.STATE_QUEUED || state == Download.STATE_RESTARTING
val isRunning: Boolean get() = state == Download.STATE_DOWNLOADING
val isActive: Boolean get() = isPending || isRunning
val isActive: Boolean get() = isPending || isRunning || retrying
}
/**
@@ -48,10 +50,13 @@ data class OfflineState(
class DownloadTracker(
private val context: Context,
private val downloadManager: DownloadManager,
scope: CoroutineScope,
private val scope: CoroutineScope,
) {
private val indexed = MutableStateFlow<Map<String, OfflineState>>(emptyMap())
/** Identifiants dont une relance est programmée. */
private val retrying = MutableStateFlow(emptySet<String>())
private val ticker = flow {
while (true) {
emit(Unit)
@@ -60,20 +65,58 @@ class DownloadTracker(
}
val states: StateFlow<Map<String, OfflineState>> =
indexed.combine(ticker) { snapshot, _ ->
snapshot + downloadManager.currentDownloads.associate { it.request.id to it.toOfflineState() }
combine(indexed, ticker, retrying) { snapshot, _, pending ->
val live = snapshot +
downloadManager.currentDownloads.associate { it.request.id to it.toOfflineState() }
live.mapValues { (id, state) -> state.copy(retrying = id in pending) }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/**
* Tentatives déjà consommées par téléchargement.
*
* Un serveur qui coupe, un réseau qui vacille : l'échec est le plus souvent passager. On
* relance donc seul, en espaçant les essais, plutôt que d'attendre un geste de l'utilisateur.
* Le compteur vit en mémoire — au redémarrage, WorkManager reprend les téléchargements et le
* décompte repart, ce qui est le comportement voulu.
*/
private val attempts = mutableMapOf<String, Int>()
private val listener = object : DownloadManager.Listener {
override fun onDownloadChanged(manager: DownloadManager, download: Download, finalException: Exception?) {
when (download.state) {
Download.STATE_COMPLETED -> attempts.remove(download.request.id)
Download.STATE_FAILED -> scheduleRetry(download)
}
scope.launch { reload() }
}
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
attempts.remove(download.request.id)
scope.launch { reload() }
}
}
/** Relance après une pause croissante : 5 s, 15 s, puis 45 s. Au-delà, l'échec est signalé. */
private fun scheduleRetry(download: Download) {
val id = download.request.id
val used = attempts.getOrDefault(id, 0)
if (used >= RETRY_DELAYS_MS.size) return
attempts[id] = used + 1
retrying.value = retrying.value + id
scope.launch {
delay(RETRY_DELAYS_MS[used])
retrying.value = retrying.value - id
// Ré-ajouter la même requête relance un téléchargement en échec.
DownloadService.sendAddDownload(
context,
NoxelyDownloadService::class.java,
download.request,
false,
)
}
}
init {
downloadManager.addListener(listener)
scope.launch { reload() }
@@ -81,18 +124,20 @@ class DownloadTracker(
fun download(entry: MediaEntry) {
if (!entry.isRemote) return
// Une relance manuelle repart d'un compteur neuf.
attempts.remove(entry.id)
val request = DownloadRequest.Builder(entry.id, entry.uri.toUri())
.setData(entry.title.toByteArray())
.build()
DownloadService.sendAddDownload(context, CachetteDownloadService::class.java, request, false)
DownloadService.sendAddDownload(context, NoxelyDownloadService::class.java, request, false)
}
fun remove(id: String) {
DownloadService.sendRemoveDownload(context, CachetteDownloadService::class.java, id, false)
DownloadService.sendRemoveDownload(context, NoxelyDownloadService::class.java, id, false)
}
fun removeAll() {
DownloadService.sendRemoveAllDownloads(context, CachetteDownloadService::class.java, false)
DownloadService.sendRemoveAllDownloads(context, NoxelyDownloadService::class.java, false)
}
private suspend fun reload() = withContext(Dispatchers.IO) {
@@ -108,6 +153,10 @@ class DownloadTracker(
indexed.value = snapshot
}
private companion object {
val RETRY_DELAYS_MS = longArrayOf(5_000, 15_000, 45_000)
}
private fun Download.toOfflineState() = OfflineState(
id = request.id,
state = state,
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.offline
package fr.tuxgyver.noxely.offline
import android.app.Notification
import androidx.annotation.OptIn
@@ -9,15 +9,15 @@ import androidx.media3.exoplayer.offline.DownloadService
import androidx.media3.exoplayer.scheduler.Scheduler
import androidx.media3.exoplayer.workmanager.WorkManagerScheduler
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import fr.tuxgyver.cachette.CachetteApp
import fr.tuxgyver.cachette.R
import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.noxely.R
/**
* Service de mise en cache. Il tourne en premier plan pendant les téléchargements et délègue la
* reprise après coupure réseau ou redémarrage à WorkManager.
*/
@OptIn(UnstableApi::class)
class CachetteDownloadService : DownloadService(
class NoxelyDownloadService : DownloadService(
NOTIFICATION_ID,
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
CHANNEL_ID,
@@ -29,7 +29,7 @@ class CachetteDownloadService : DownloadService(
}
override fun getDownloadManager(): DownloadManager =
(application as CachetteApp).container.offline.downloadManager
(application as NoxelyApp).container.offline.downloadManager
override fun getScheduler(): Scheduler = WorkManagerScheduler(this, WORK_NAME)
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.offline
package fr.tuxgyver.noxely.offline
import android.content.Context
import androidx.annotation.OptIn
@@ -31,7 +31,7 @@ class OfflineModule(private val context: Context) {
private val httpDataSourceFactory: DataSource.Factory by lazy {
DefaultHttpDataSource.Factory()
.setUserAgent("Cachette/1.0 (Android)")
.setUserAgent("Noxely/1.0 (Android)")
.setAllowCrossProtocolRedirects(true)
.setConnectTimeoutMs(20_000)
.setReadTimeoutMs(30_000)
@@ -1,9 +1,9 @@
package fr.tuxgyver.cachette.playback
package fr.tuxgyver.noxely.playback
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaEntry
/**
* Les `MediaItem` transmis par un contrôleur perdent leur URI en chemin : c'est le service qui la
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.playback
package fr.tuxgyver.noxely.playback
import android.app.PendingIntent
import android.content.Intent
@@ -13,8 +13,8 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import fr.tuxgyver.cachette.CachetteApp
import fr.tuxgyver.cachette.MainActivity
import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.noxely.MainActivity
/** Lecture audio et vidéo en arrière-plan, avec notification et commandes système. */
@OptIn(UnstableApi::class)
@@ -24,7 +24,7 @@ class PlaybackService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
val container = (application as CachetteApp).container
val container = (application as NoxelyApp).container
val player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(container.offline.playbackDataSourceFactory()))
@@ -76,7 +76,7 @@ class PlaybackService : MediaSessionService() {
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> {
val library = (application as CachetteApp).container.library
val library = (application as NoxelyApp).container.library
val resolved = mediaItems.mapTo(mutableListOf()) { item ->
library.entry(item.mediaId)?.toMediaItem() ?: item
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.playback
package fr.tuxgyver.noxely.playback
import android.content.ComponentName
import android.content.Context
@@ -6,8 +6,8 @@ import androidx.core.content.ContextCompat
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -0,0 +1,127 @@
package fr.tuxgyver.noxely.radio
import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.net.URLEncoder
/**
* Annuaire des stations, via l'API communautaire Radio Browser (sans clé).
*
* Les flux HLS sont écartés : ils se lisent, mais ne s'enregistrent pas en copiant les octets,
* et proposer un bouton qui échouerait vaudrait moins que ne pas le proposer.
*/
class RadioDirectory {
private val json = Json { ignoreUnknownKeys = true }
/** Les stations les plus écoutées, éventuellement restreintes à un genre. */
suspend fun popular(genre: String? = null): Result<List<RadioStation>> =
query(buildString {
append("?limit=60&hidebroken=true&order=clickcount&reverse=true")
genre?.let { append("&tag=${URLEncoder.encode(it, "UTF-8")}") }
})
suspend fun search(name: String): Result<List<RadioStation>> =
query("?limit=60&hidebroken=true&order=clickcount&reverse=true" +
"&name=${URLEncoder.encode(name.trim(), "UTF-8")}")
/**
* Le morceau diffusé à l'instant par une station.
*
* Aucun annuaire ne l'expose : il faut ouvrir le flux, lire le premier bloc de métadonnées
* ICY, puis refermer. C'est une connexion par station — d'où l'appel par petits paquets et
* la mise en cache par l'appelant plutôt qu'un balayage systématique.
*/
suspend fun nowPlaying(streamUrl: String): String? = withContext(Dispatchers.IO) {
runCatching {
val connection = openConnection(streamUrl, readTimeoutMs = 8_000).apply {
setRequestProperty("Icy-MetaData", "1")
}
try {
if (connection.responseCode !in 200..299) return@runCatching null
val interval = connection.getHeaderFieldInt("icy-metaint", 0)
if (interval <= 0) return@runCatching null
connection.inputStream.use { input ->
val skip = ByteArray(interval)
var filled = 0
while (filled < interval) {
val read = input.read(skip, filled, interval - filled)
if (read < 0) return@runCatching null
filled += read
}
val length = input.read().takeIf { it >= 0 }?.times(16) ?: return@runCatching null
if (length == 0) return@runCatching null
val meta = ByteArray(length)
filled = 0
while (filled < length) {
val read = input.read(meta, filled, length - filled)
if (read < 0) return@runCatching null
filled += read
}
STREAM_TITLE.find(String(meta, Charsets.UTF_8))
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
}
} finally {
connection.disconnect()
}
}.getOrNull()
}
private suspend fun query(suffix: String): Result<List<RadioStation>> = withContext(Dispatchers.IO) {
runCatching {
val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000)
val body = try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("L'annuaire a répondu ${connection.responseCode}")
}
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
(json.parseToJsonElement(body) as? JsonArray).orEmpty()
.mapNotNull { it as? JsonObject }
.filter { it.number("hls") == 0L }
.mapNotNull { station ->
val url = station.string("url_resolved")?.takeIf { it.startsWith("http") }
?: return@mapNotNull null
RadioStation(
id = station.string("stationuuid") ?: url,
name = station.string("name")?.trim() ?: "Station",
streamUrl = url,
codec = station.string("codec").orEmpty(),
bitrate = station.number("bitrate")?.toInt() ?: 0,
country = station.string("country").orEmpty(),
tags = station.string("tags").orEmpty(),
faviconUrl = station.string("favicon")?.takeIf { it.startsWith("http") },
)
}
.distinctBy { it.streamUrl }
}
}
private companion object {
const val BASE = "https://de1.api.radio-browser.info/json/stations/search"
/** Le titre court jusqu'à la fin du champ ICY : les apostrophes y sont fréquentes. */
val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
/** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
fun JsonObject.string(key: String): String? =
(this[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
fun JsonObject.number(key: String): Long? =
(this[key] as? JsonPrimitive)?.content?.substringBefore('.')?.toLongOrNull()
}
}
/** Genres offerts en filtre rapide. */
val RADIO_GENRES = listOf("pop", "rock", "jazz", "classical", "electronic", "news", "chanson")
@@ -0,0 +1,20 @@
package fr.tuxgyver.noxely.radio
/** Une station de radio et son flux direct. */
data class RadioStation(
val id: String,
val name: String,
val streamUrl: String,
val codec: String,
val bitrate: Int,
val country: String,
val tags: String,
val faviconUrl: String?,
) {
val subtitle: String
get() = listOfNotNull(
country.takeIf { it.isNotBlank() },
tags.split(',').firstOrNull()?.trim()?.takeIf { it.isNotBlank() }?.replaceFirstChar(Char::uppercase),
bitrate.takeIf { it > 0 }?.let { "$it kbps" },
).joinToString(" · ")
}
@@ -0,0 +1,54 @@
package fr.tuxgyver.noxely.radio
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class RecordingStatus(
val station: RadioStation? = null,
val startedAt: Long = 0L,
val bytes: Long = 0L,
/** Titre annoncé par le flux, quand la station en diffuse un. */
val currentTitle: String? = null,
/** Titre attendu avant de commencer à écrire, si l'enregistrement a été programmé. */
val waitingFor: String? = null,
) {
val isRecording: Boolean get() = station != null
val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt
}
/**
* État de l'enregistrement en cours.
*
* Seul [RecordingService] y écrit ; l'interface ne fait que l'observer. Un singleton se justifie
* ici parce que l'enregistrement est unique par nature : on n'enregistre qu'une station à la fois.
*/
object Recorder {
private val _status = MutableStateFlow(RecordingStatus())
val status: StateFlow<RecordingStatus> = _status.asStateFlow()
internal fun started(station: RadioStation, waitingFor: String? = null) {
_status.value = RecordingStatus(station, System.currentTimeMillis(), 0L, waitingFor = waitingFor)
}
/** Le morceau attendu commence : l'écriture démarre maintenant. */
internal fun armed(title: String) {
_status.value = _status.value.copy(
waitingFor = null,
currentTitle = title,
startedAt = System.currentTimeMillis(),
)
}
internal fun progress(bytes: Long) {
_status.value = _status.value.copy(bytes = bytes)
}
internal fun title(title: String) {
_status.value = _status.value.copy(currentTitle = title)
}
internal fun stopped() {
_status.value = RecordingStatus()
}
}
@@ -0,0 +1,380 @@
package fr.tuxgyver.noxely.radio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import fr.tuxgyver.noxely.NoxelyApp
import fr.tuxgyver.noxely.MainActivity
import fr.tuxgyver.noxely.R
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Enregistre un flux radio en copiant ses octets dans un fichier.
*
* Un direct n'a pas de fin : `DownloadManager`, qui attend une taille et une complétion, ne peut
* pas s'en charger. L'enregistrement tourne donc en service de premier plan jusqu'à ce que
* l'utilisateur l'arrête, et le fichier obtenu rejoint la bibliothèque comme média local.
*/
class RecordingService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
private var sink: RecordingSink? = null
private var sinkTitle: String = ""
private var station: RadioStation? = null
private var firstTitle: String? = null
private var trigger: String? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
finish()
return START_NOT_STICKY
}
ACTION_START -> {
val incoming = intent.toStation() ?: run {
stopSelf()
return START_NOT_STICKY
}
if (job?.isActive == true) return START_STICKY
trigger = intent.getStringExtra(EXTRA_TRIGGER)?.takeIf { it.isNotBlank() }
begin(incoming)
}
}
return START_STICKY
}
private fun begin(incoming: RadioStation) {
station = incoming
firstTitle = null
Recorder.started(incoming, trigger)
startForegroundCompat(notification(incoming, 0L))
val stamp = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.FRANCE).format(Date())
val extension = incoming.extension()
sinkTitle = "${incoming.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}"
val output = RecordingSink(
context = this,
fileName = "${incoming.name.sanitized()}-$stamp.$extension",
mimeType = if (extension == "aac") "audio/aac" else "audio/mpeg",
)
sink = output
job = scope.launch {
runCatching {
// « Icy-MetaData: 1 » demande à la station d'intercaler le titre en cours dans le
// flux. Ces octets ne sont pas de l'audio : ils doivent être retirés avant écriture,
// sinon le fichier obtenu est corrompu.
val connection = openConnection(incoming.streamUrl, readTimeoutMs = 30_000).apply {
setRequestProperty("Icy-MetaData", "1")
}
try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Flux indisponible (${connection.responseCode})")
}
val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0)
connection.inputStream.use { input ->
output.open().use { stream ->
if (metaInterval > 0) {
copyWithTitles(input, stream, metaInterval, incoming)
} else if (trigger != null) {
// Sans métadonnées, impossible de savoir quand le morceau passe.
throw IllegalStateException(
"${incoming.name} n'annonce pas ses titres : l'attente est impossible"
)
} else {
copyPlain(input, stream, incoming)
}
stream.flush()
}
}
} finally {
connection.disconnect()
}
}
// Fin du flux ou coupure réseau : ce qui a été capté reste valable.
finish()
}
}
/** Flux sans métadonnées : simple recopie. */
private suspend fun copyPlain(
input: java.io.InputStream,
output: java.io.OutputStream,
station: RadioStation,
) {
val buffer = ByteArray(32 * 1024)
var total = 0L
var lastNotified = 0L
while (currentCoroutineContext().isActive) {
val read = input.read(buffer)
if (read <= 0) break
output.write(buffer, 0, read)
total += read
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
lastNotified = total
Recorder.progress(total)
notifyProgress(station, total)
}
}
Recorder.progress(total)
}
/**
* Flux ICY : `interval` octets d'audio, puis un bloc de métadonnées à retirer.
* Le premier titre rencontré nomme l'enregistrement ; les suivants alimentent la notification.
*/
private suspend fun copyWithTitles(
input: java.io.InputStream,
output: java.io.OutputStream,
interval: Int,
station: RadioStation,
) {
val wanted = trigger
val audio = ByteArray(interval)
var total = 0L
var lastNotified = 0L
// Sans titre attendu, on écrit dès la première seconde ; sinon on patiente.
var writing = wanted == null
var capturedTitle: String? = null
while (currentCoroutineContext().isActive) {
if (!input.readFully(audio, interval)) break
if (writing) {
output.write(audio)
total += interval
}
val lengthByte = input.read()
if (lengthByte < 0) break
val metaLength = lengthByte * 16
if (metaLength > 0) {
val meta = ByteArray(metaLength)
if (!input.readFully(meta, metaLength)) break
val announced = STREAM_TITLE
.find(String(meta, Charsets.UTF_8))
?.groupValues?.get(1)
?.trim()
?.takeIf { it.isNotBlank() }
if (announced != null) {
Recorder.title(announced)
when {
wanted == null -> if (firstTitle == null) firstTitle = announced
!writing && announced.contains(wanted, ignoreCase = true) -> {
// Le morceau attendu commence : on enregistre à partir d'ici.
writing = true
capturedTitle = announced
firstTitle = announced
Recorder.armed(announced)
}
writing && capturedTitle != null && announced != capturedTitle -> {
// Le morceau est terminé : on s'arrête sans attendre le suivant.
break
}
}
notifyProgress(station, total)
}
}
if (total - lastNotified > NOTIFY_EVERY_BYTES) {
lastNotified = total
Recorder.progress(total)
notifyProgress(station, total)
}
}
Recorder.progress(total)
}
/** Lit exactement [count] octets, ou renvoie false si le flux s'interrompt avant. */
private fun java.io.InputStream.readFully(target: ByteArray, count: Int): Boolean {
var filled = 0
while (filled < count) {
val read = read(target, filled, count - filled)
if (read < 0) return false
filled += read
}
return true
}
/** Clôt l'enregistrement et verse le fichier obtenu dans la bibliothèque. */
private fun finish() {
job?.cancel()
job = null
val output = sink
val recorded = station
val captured = firstTitle
sink = null
station = null
trigger = null
if (output != null && recorded != null) {
val elapsed = Recorder.status.value.elapsedMs
// Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant.
val title = captured ?: sinkTitle
val published = output.finish(title, elapsed)
if (published != null) {
val (uri, size) = published
val container = (application as NoxelyApp).container
val entry = MediaEntry(
id = "rec:${uri.hashCode()}",
source = SourceKind.RECORDING,
kind = MediaKind.AUDIO,
title = title,
subtitle = if (captured != null) recorded.name else "Enregistrement radio",
uri = uri,
artworkUri = recorded.faviconUrl,
durationMs = elapsed,
addedAt = System.currentTimeMillis(),
sizeBytes = size,
)
CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) }
}
}
firstTitle = null
Recorder.stopped()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun notifyProgress(station: RadioStation, bytes: Long) {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, notification(station, bytes))
}
private fun notification(station: RadioStation, bytes: Long): Notification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Enregistrement radio", NotificationManager.IMPORTANCE_LOW)
)
}
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val stop = PendingIntent.getService(
this,
1,
Intent(this, RecordingService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline)
.setContentTitle(
if (Recorder.status.value.waitingFor != null) "En attente sur ${station.name}"
else "Enregistrement de ${station.name}"
)
.setContentText(
Recorder.status.value.currentTitle
?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…"
)
.setContentIntent(open)
.addAction(0, "Arrêter", stop)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun Intent.toStation(): RadioStation? {
val url = getStringExtra(EXTRA_URL) ?: return null
return RadioStation(
id = getStringExtra(EXTRA_ID).orEmpty(),
name = getStringExtra(EXTRA_NAME) ?: "Radio",
streamUrl = url,
codec = getStringExtra(EXTRA_CODEC).orEmpty(),
bitrate = getIntExtra(EXTRA_BITRATE, 0),
country = "",
tags = "",
faviconUrl = getStringExtra(EXTRA_FAVICON),
)
}
private fun RadioStation.extension(): String =
if (codec.equals("AAC", ignoreCase = true) || codec.startsWith("AAC", true)) "aac" else "mp3"
private fun String.sanitized(): String =
replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(' ', '_').take(40).ifBlank { "radio" }
companion object {
private const val ACTION_START = "fr.tuxgyver.noxely.RECORD_START"
private const val ACTION_STOP = "fr.tuxgyver.noxely.RECORD_STOP"
private const val EXTRA_ID = "id"
private const val EXTRA_NAME = "name"
private const val EXTRA_URL = "url"
private const val EXTRA_CODEC = "codec"
private const val EXTRA_BITRATE = "bitrate"
private const val EXTRA_FAVICON = "favicon"
private const val EXTRA_TRIGGER = "trigger"
private const val NOTIFICATION_ID = 4401
private const val CHANNEL_ID = "cachette_recording"
private const val NOTIFY_EVERY_BYTES = 256 * 1024L
private const val MIN_USEFUL_BYTES = 16 * 1024L
private val STREAM_TITLE = Regex("StreamTitle='(.*?)';")
fun start(context: Context, station: RadioStation, whenTitleContains: String? = null) {
val intent = Intent(context, RecordingService::class.java)
.setAction(ACTION_START)
.putExtra(EXTRA_ID, station.id)
.putExtra(EXTRA_NAME, station.name)
.putExtra(EXTRA_URL, station.streamUrl)
.putExtra(EXTRA_CODEC, station.codec)
.putExtra(EXTRA_BITRATE, station.bitrate)
.putExtra(EXTRA_FAVICON, station.faviconUrl)
.putExtra(EXTRA_TRIGGER, whenTitleContains)
context.startForegroundService(intent)
}
fun stop(context: Context) {
context.startService(
Intent(context, RecordingService::class.java).setAction(ACTION_STOP)
)
}
}
}
@@ -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)
}
}
@@ -0,0 +1,158 @@
package fr.tuxgyver.noxely.sources
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import java.net.URLEncoder
/**
* Recherche dans archive.org.
*
* La recherche ne renvoie que des identifiants ; le fichier réellement lisible n'est déterminé
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
* trente éléments dont vingt-neuf seront ignorés.
*/
class ArchiveOrgCatalog(
override val id: CatalogId = CatalogId.ARCHIVE,
/**
* Collections auxquelles se restreindre. Vide : tout archive.org.
* `netlabels` et `freemusicarchive` ne contiennent que des publications sous licence libre,
* ce qui donne un fonds indépendant sans les dépôts de titres commerciaux qu'on croise
* ailleurs sur le site.
*/
private val collections: List<String> = emptyList(),
) : Catalog {
override val languages = COMMON_LANGUAGES
private val json = Json { ignoreUnknownKeys = true }
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
withContext(Dispatchers.IO) {
runCatching {
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
// dès la recherche plutôt que de les proposer puis d'échouer.
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
val collectionClause =
collections.takeIf { it.isNotEmpty() }
?.joinToString(" OR ")
?.let { " AND collection:($it)" }
.orEmpty()
val q = URLEncoder.encode(
"($query) AND mediatype:(audio OR movies)$languageClause$collectionClause" +
" AND NOT access-restricted-item:true",
"UTF-8",
)
val url = "https://archive.org/advancedsearch.php?q=$q" +
"&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype&fl%5B%5D=item_size" +
"&rows=30&page=1&output=json"
val body = fetch(url)
val docs = json.parseToJsonElement(body).jsonObject["response"]
?.jsonObject?.get("docs") as? JsonArray
?: throw IllegalStateException("Réponse inattendue d'archive.org")
docs.mapNotNull { element ->
val doc = element as? JsonObject ?: return@mapNotNull null
val identifier = doc.text("identifier") ?: return@mapNotNull null
CatalogItem(
id = identifier,
title = doc.text("title") ?: identifier,
subtitle = doc.text("creator") ?: "Internet Archive",
artworkUri = "https://archive.org/services/img/$identifier",
hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio",
sizeBytes = doc.text("item_size")?.toLongOrNull(),
)
}
}
}
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
runCatching {
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
if (metadata["metadata"]?.jsonObject?.text("access-restricted-item") == "true") {
throw IllegalStateException("« ${item.title} » est en accès restreint sur archive.org")
}
val wantsVideo = item.hint == "Vidéo"
val extensions = if (wantsVideo) VIDEO_EXTENSIONS else AUDIO_EXTENSIONS
val files = (metadata["files"] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject }
val playable = files.filter { file ->
val name = file.text("name").orEmpty().lowercase()
extensions.any { name.endsWith(it) }
}
if (playable.isEmpty()) {
throw IllegalStateException(
"Aucun fichier ${if (wantsVideo) "vidéo" else "audio"} lisible dans « ${item.title} »"
)
}
// Un élément expose l'original et ses dérivés du même morceau. On regroupe par nom de
// base et on garde le plus léger de chaque groupe : autant de pistes que d'œuvres,
// pas autant que de formats.
playable.groupBy { it.text("name")!!.substringBeforeLast('.').lowercase() }
.values
.mapNotNull { variants ->
variants.minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE }
}
.sortedBy { it.text("name") }
.map { file ->
val name = file.text("name")!!
val seconds = file.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
MediaEntry(
id = stableId("ia:", "${item.id}/$name"),
source = SourceKind.URL,
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
title = file.text("title") ?: name.substringBeforeLast('.'),
subtitle = item.title,
uri = "https://archive.org/download/${item.id}/${encodePath(name)}",
artworkUri = item.artworkUri,
durationMs = seconds * 1000,
addedAt = System.currentTimeMillis(),
sizeBytes = file.text("size")?.toLongOrNull(),
)
}
}
}
/** Encode le nom de fichier sans toucher aux séparateurs de chemin. */
private fun encodePath(name: String): String =
name.split('/').joinToString("/") {
URLEncoder.encode(it, "UTF-8").replace("+", "%20")
}
private fun fetch(url: String): String {
val connection = openConnection(url)
return try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("archive.org a répondu ${connection.responseCode}")
}
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
}
private companion object {
val AUDIO_EXTENSIONS = listOf(".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav")
val VIDEO_EXTENSIONS = listOf(".mp4", ".m4v", ".webm", ".ogv")
fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
}
}
/** archive.org renvoie tantôt une chaîne, tantôt un tableau : on prend la première valeur utile. */
internal fun JsonObject.text(key: String): String? = when (val value = this[key]) {
is JsonPrimitive -> value.content.takeIf { it.isNotBlank() }
is JsonArray -> value.firstOrNull()?.let { (it as? JsonPrimitive)?.content }?.takeIf { it.isNotBlank() }
else -> null
}
@@ -0,0 +1,77 @@
package fr.tuxgyver.noxely.sources
import fr.tuxgyver.noxely.model.MediaEntry
/** Un résultat de recherche : un album, un livre ou un morceau, pas encore un fichier. */
data class CatalogItem(
val id: String,
val title: String,
val subtitle: String,
val artworkUri: String? = null,
val hint: String = "",
/** Taille de l'ensemble, quand le catalogue l'annonce. */
val sizeBytes: Long? = null,
)
/** Langue proposée au filtrage. [code] est la valeur attendue par l'API du catalogue. */
data class CatalogLanguage(val code: String, val label: String)
/** Catalogue public interrogeable sans compte ni clé d'API. */
interface Catalog {
val id: CatalogId
/** Vide si le catalogue ne distingue pas les langues (la musique instrumentale, typiquement). */
val languages: List<CatalogLanguage> get() = emptyList()
suspend fun search(query: String, language: String? = null): Result<List<CatalogItem>>
/**
* Les pistes d'un résultat, avec leur taille.
*
* Un album ou un livre en compte plusieurs : c'est à l'utilisateur de choisir lesquelles il
* emporte, pas à l'application de décider pour lui.
*/
suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>>
}
enum class CatalogId(
val title: String,
val description: String,
val license: String,
val suggestion: String,
) {
ARCHIVE(
title = "Internet Archive",
description = "Musique, concerts et films versés au domaine public ou sous licence libre.",
license = "Domaine public / Creative Commons",
suggestion = "Grateful Dead",
),
LIBRIVOX(
title = "LibriVox",
description = "Livres audio lus par des bénévoles, tous dans le domaine public.",
license = "Domaine public",
suggestion = "Jules Verne",
),
UNDERGROUND(
title = "Underground",
description = "Netlabels et Free Music Archive : des dizaines de milliers de morceaux d'artistes indépendants, publiés sous licence libre.",
license = "Creative Commons / netlabels",
suggestion = "ambient",
),
WIKIMEDIA(
title = "Wikimedia Commons",
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
license = "Domaine public / Creative Commons",
suggestion = "Chopin",
),
}
/** Langues les plus fournies, communes à LibriVox et archive.org. */
val COMMON_LANGUAGES = listOf(
CatalogLanguage("French", "Français"),
CatalogLanguage("English", "Anglais"),
CatalogLanguage("German", "Allemand"),
CatalogLanguage("Spanish", "Espagnol"),
CatalogLanguage("Italian", "Italien"),
CatalogLanguage("Dutch", "Néerlandais"),
)
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import java.net.HttpURLConnection
import java.net.URL
@@ -9,11 +9,15 @@ internal fun stableId(prefix: String, value: String): String {
return prefix + digest.joinToString("") { "%02x".format(it) }.take(16)
}
internal fun openConnection(url: String, method: String = "GET"): HttpURLConnection =
internal fun openConnection(
url: String,
method: String = "GET",
readTimeoutMs: Int = 20_000,
): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
instanceFollowRedirects = true
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", "Cachette/1.0 (Android)")
readTimeout = readTimeoutMs
setRequestProperty("User-Agent", "Noxely/1.1 (Android)")
}
@@ -0,0 +1,106 @@
package fr.tuxgyver.noxely.sources
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.net.URLEncoder
/**
* Recherche dans LibriVox.
*
* L'API a deux pièges, tous deux vérifiés contre le service réel :
* le champ `title` fait une correspondance **exacte** sauf s'il est préfixé par `^`
* (`title=Alice` ne donne rien, `title=^Alice` donne vingt-trois livres), et le champ `author`
* ne matche que le **nom de famille** (`author=Jules Verne` ne donne rien, `author=Verne` si).
* Un « aucun résultat » se présente enfin sous la forme d'un HTTP 404, pas d'une liste vide.
*
* D'où trois interrogations combinées et dédoublonnées, plutôt qu'une seule qui échouerait.
*
* Chaque livre publie un flux RSS : la résolution réutilise l'import de podcast plutôt que de
* redécrire un parcours de chapitres.
*/
class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
override val id = CatalogId.LIBRIVOX
override val languages = COMMON_LANGUAGES
private val json = Json { ignoreUnknownKeys = true }
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
withContext(Dispatchers.IO) {
runCatching {
// Le paramètre `language` de l'API est accepté puis ignoré : vérifié, `language=French`
// seul renvoie des livres anglais. Le tri se fait donc sur le champ `language` de
// chaque livre, et on demande davantage de résultats pour qu'il en reste après filtrage.
val limit = if (language == null) 25 else 100
val cleaned = query.trim()
val term = URLEncoder.encode(cleaned, "UTF-8")
val words = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() }
val criteria = buildList {
add("title=^$term")
add("author=$term")
// « Jules Verne » ne matche que sur « Verne » : on retente avec le nom de famille.
if (words.size > 1) add("author=${URLEncoder.encode(words.last(), "UTF-8")}")
}
// Les trois critères sont indépendants : les lancer en parallèle ramène l'attente
// à celle de la requête la plus lente, au lieu de leur somme.
coroutineScope {
criteria.map { async { books(it, limit, language) } }.awaitAll()
}.flatten().distinctBy { it.id }
}
}
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> =
rss.import(item.id).map { feed -> feed.episodes }
private fun books(criterion: String, limit: Int, language: String?): List<CatalogItem> {
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=$limit&$criterion"
// Le service met 14 à 16 secondes à répondre, quelle que soit la taille demandée :
// c'est sa latence propre, pas le volume. D'où une patience adaptée.
val connection = openConnection(url, readTimeoutMs = 45_000)
val body = try {
when (connection.responseCode) {
// LibriVox répond 404 quand le critère ne donne rien : ce n'est pas une panne.
404 -> return emptyList()
in 200..299 -> connection.inputStream.bufferedReader().readText()
else -> throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
}
} finally {
connection.disconnect()
}
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray ?: return emptyList()
return books.mapNotNull { element ->
val book = element as? JsonObject ?: return@mapNotNull null
if (language != null && !book.text("language").equals(language, ignoreCase = true)) {
return@mapNotNull null
}
val feed = book.text("url_rss") ?: return@mapNotNull null
CatalogItem(
id = feed,
title = book.text("title") ?: "Livre audio",
subtitle = book.authors(),
hint = book.text("totaltime")?.let { "Livre audio · $it" } ?: "Livre audio",
)
}
}
private fun JsonObject.authors(): String {
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
return authors.mapNotNull { it as? JsonObject }
.joinToString(", ") { author ->
listOfNotNull(author.text("first_name"), author.text("last_name")).joinToString(" ")
}
.ifBlank { "LibriVox" }
}
}
@@ -1,9 +1,9 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import android.util.Xml
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
@@ -1,10 +1,10 @@
package fr.tuxgyver.cachette.sources
package fr.tuxgyver.noxely.sources
import android.net.Uri
import androidx.core.net.toUri
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -0,0 +1,96 @@
package fr.tuxgyver.noxely.sources
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.model.SourceKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import java.net.URLEncoder
/**
* Recherche de fichiers audio sur Wikimedia Commons.
*
* Retenu après avoir écarté deux candidats vérifiés : Openverse refuse désormais l'accès anonyme
* (401 sur huit requêtes sur huit) et ccMixter exige un en-tête `Referer` que Media3 ne permet pas
* de poser par téléchargement. Commons, lui, sert ses fichiers directement depuis
* upload.wikimedia.org, sans clé ni en-tête particulier, et annonce leur taille.
*
* Chaque résultat est déjà un fichier unique : la liste de pistes n'en contient donc qu'une.
*/
class WikimediaCatalog : Catalog {
override val id = CatalogId.WIKIMEDIA
private val json = Json { ignoreUnknownKeys = true }
override suspend fun search(query: String, language: String?): Result<List<CatalogItem>> =
withContext(Dispatchers.IO) {
runCatching {
val search = URLEncoder.encode("filetype:audio ${query.trim()}", "UTF-8")
val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json" +
"&generator=search&gsrsearch=$search&gsrnamespace=6&gsrlimit=30" +
"&prop=imageinfo&iiprop=url%7Csize%7Cmime%7Cextmetadata"
val connection = openConnection(url, readTimeoutMs = 30_000)
val body = try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Commons a répondu ${connection.responseCode}")
}
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
val pages = json.parseToJsonElement(body).jsonObject["query"]
?.jsonObject?.get("pages")?.jsonObject
?: return@runCatching emptyList()
pages.values.mapNotNull { element ->
val page = element as? JsonObject ?: return@mapNotNull null
val info = page["imageinfo"]?.jsonArray?.firstOrNull()?.jsonObject
?: return@mapNotNull null
val fileUrl = info.text("url") ?: return@mapNotNull null
if (!PLAYABLE.any { fileUrl.substringBefore('?').lowercase().endsWith(it) }) {
return@mapNotNull null
}
val licence = info["extmetadata"]?.jsonObject
?.get("LicenseShortName")?.jsonObject?.text("value")
CatalogItem(
id = fileUrl,
// Les titres Commons sont préfixés « File: » : inutile à l'écran.
title = page.text("title")?.removePrefix("File:")?.substringBeforeLast('.')
?: "Sans titre",
subtitle = "Wikimedia Commons",
hint = licence ?: "Domaine public / CC",
sizeBytes = info.text("size")?.toLongOrNull(),
)
}
}
}
override suspend fun tracks(item: CatalogItem): Result<List<MediaEntry>> = Result.success(
listOf(
MediaEntry(
id = stableId("wm:", item.id),
source = SourceKind.URL,
kind = MediaKind.AUDIO,
title = item.title,
subtitle = item.subtitle,
uri = item.id,
addedAt = System.currentTimeMillis(),
sizeBytes = item.sizeBytes,
)
)
)
private companion object {
/** Formats que Media3 sait lire parmi ceux que Commons héberge. */
val PLAYABLE = listOf(".ogg", ".oga", ".opus", ".flac", ".wav", ".mp3", ".m4a")
}
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui
package fr.tuxgyver.noxely.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.LibraryMusic
import androidx.compose.material.icons.rounded.PlaylistPlay
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
@@ -34,35 +35,47 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen
import fr.tuxgyver.cachette.ui.library.LibraryScreen
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
import fr.tuxgyver.cachette.ui.player.MiniPlayer
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.sources.CatalogId
import fr.tuxgyver.noxely.ui.about.AboutScreen
import fr.tuxgyver.noxely.ui.browse.CatalogBrowseScreen
import fr.tuxgyver.noxely.ui.browse.CatalogItemScreen
import fr.tuxgyver.noxely.ui.browse.SourceBrowseScreen
import fr.tuxgyver.noxely.ui.library.LibraryScreen
import fr.tuxgyver.noxely.ui.offline.DownloadsScreen
import fr.tuxgyver.noxely.ui.player.MiniPlayer
import fr.tuxgyver.noxely.ui.radio.RadioScreen
import fr.tuxgyver.noxely.ui.playlists.AddToPlaylistSheet
import fr.tuxgyver.noxely.ui.playlists.PlaylistDetailScreen
import fr.tuxgyver.noxely.ui.playlists.PlaylistsScreen
import fr.tuxgyver.noxely.ui.player.NowPlayingScreen
import fr.tuxgyver.noxely.ui.sources.SourcesScreen
import kotlinx.coroutines.launch
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
PLAYLISTS("playlists", "Playlists", Icons.Rounded.PlaylistPlay),
SOURCES("sources", "Sources", Icons.Rounded.Tune),
}
private const val BROWSE_ROUTE = "browse/{source}"
private const val CATALOG_ROUTE = "catalog/{catalog}"
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
private const val PLAYLIST_ROUTE = "playlist/{playlist}"
private const val RADIO_ROUTE = "radio"
private const val ABOUT_ROUTE = "about"
@Composable
fun CachetteRoot(container: AppContainer) {
fun NoxelyRoot(container: AppContainer) {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
var expanded by remember { mutableStateOf(false) }
var playlistTarget by remember { mutableStateOf<MediaEntry?>(null) }
val playerState by container.player.state.collectAsStateWithLifecycle()
val position by container.player.positionMs.collectAsStateWithLifecycle()
@@ -111,6 +124,7 @@ fun CachetteRoot(container: AppContainer) {
container = container,
currentMediaId = playerState.mediaId,
onPlay = play,
onAddToPlaylist = { playlistTarget = it },
contentPadding = padding,
)
}
@@ -120,6 +134,40 @@ fun CachetteRoot(container: AppContainer) {
container = container,
currentMediaId = playerState.mediaId,
onPlay = play,
onAddToPlaylist = { playlistTarget = it },
contentPadding = padding,
)
}
composable(Tab.PLAYLISTS.route) {
PlaylistsScreen(
container = container,
onOpen = { navController.navigate("playlist/$it") },
contentPadding = padding,
)
}
composable(ABOUT_ROUTE) {
AboutScreen(onBack = navController::popBackStack, contentPadding = padding)
}
composable(RADIO_ROUTE) {
RadioScreen(
container = container,
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onMessage = notify,
contentPadding = padding,
)
}
composable(PLAYLIST_ROUTE) { entry ->
PlaylistDetailScreen(
container = container,
playlistId = entry.arguments?.getString("playlist").orEmpty(),
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onPlay = play,
contentPadding = padding,
)
}
@@ -131,6 +179,8 @@ fun CachetteRoot(container: AppContainer) {
onMessage = notify,
onBrowseSource = { navController.navigate("browse/${it.name}") },
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
onBrowseRadio = { navController.navigate(RADIO_ROUTE) },
onOpenAbout = { navController.navigate(ABOUT_ROUTE) },
)
}
@@ -152,6 +202,21 @@ fun CachetteRoot(container: AppContainer) {
container = container,
catalogId = catalog,
onBack = navController::popBackStack,
onOpenItem = { item ->
container.openedCatalogItem = item
navController.navigate("catalog/${catalog.name}/item")
},
contentPadding = padding,
)
}
composable(CATALOG_ITEM_ROUTE) { entry ->
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
CatalogItemScreen(
container = container,
catalogId = catalog,
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onMessage = notify,
contentPadding = padding,
)
@@ -177,12 +242,26 @@ fun CachetteRoot(container: AppContainer) {
onSkipBy = container.player::skipBy,
onNext = container.player::next,
onPrevious = container.player::previous,
onStop = {
container.player.stop()
expanded = false
},
onAddToPlaylist = { entry?.let { playlistTarget = it } },
onDownload = { entry?.let(container.downloads::download) },
onRemoveOffline = { playerState.mediaId?.let(container.downloads::remove) },
)
}
}
playlistTarget?.let { target ->
AddToPlaylistSheet(
container = container,
entry = target,
onDismiss = { playlistTarget = null },
onMessage = notify,
)
}
BackHandler(enabled = expanded) { expanded = false }
}
@@ -0,0 +1,355 @@
package fr.tuxgyver.noxely.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import fr.tuxgyver.noxely.R
private val TABS = listOf(
"⭐ Fonctions",
"🛡️ Garanties",
"🧱 Technique",
"🕓 Journal",
"👤 Auteur",
)
/**
* Page « À propos » : ce que fait l'application, ce qu'elle garantit, avec quoi elle est faite,
* ce qui a changé et qui en répond. L'en-tête ne défile pas ; seul l'onglet courant défile.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit, contentPadding: PaddingValues) {
var tab by rememberSaveable { mutableIntStateOf(0) }
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("À propos") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
)
Identity()
ScrollableTabRow(selectedTabIndex = tab, edgePadding = 16.dp) {
TABS.forEachIndexed { index, label ->
Tab(
selected = tab == index,
onClick = { tab = index },
text = { Text(label, style = MaterialTheme.typography.labelLarge) },
)
}
}
when (tab) {
0 -> FeaturesTab(listPadding)
1 -> GuaranteesTab(listPadding)
2 -> StackTab(listPadding)
3 -> ChangelogTab(listPadding)
else -> AuthorTab(listPadding)
}
}
}
@Composable
private fun Identity() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Image(
painter = painterResource(R.mipmap.ic_launcher_foreground),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp)),
)
Spacer(Modifier.size(6.dp))
Text(AppInfo.NAME, style = MaterialTheme.typography.headlineSmall)
Text(
text = AppInfo.TAGLINE,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Pill("v${AppInfo.VERSION}", MaterialTheme.colorScheme.primary)
// Rouge tant qu'aucune licence n'est déclarée : une garantie qui manque doit se voir.
Pill(
text = AppInfo.LICENSE,
color = if (AppInfo.LICENSE_DEFINED) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.error,
)
Pill("Hors ligne", Color(0xFF2E7D32))
}
Spacer(Modifier.size(12.dp))
}
}
@Composable
private fun Pill(text: String, color: Color) {
Surface(color = color.copy(alpha = 0.14f), shape = CircleShape) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = color,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
}
@Composable
private fun FeaturesTab(padding: PaddingValues) {
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(AppInfo.FEATURES) { (title, description) ->
Column {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item { Spacer(Modifier.size(16.dp)) }
}
}
@Composable
private fun GuaranteesTab(padding: PaddingValues) {
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
item {
Text(
text = AppInfo.GUARANTEES_INTRO,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.size(4.dp))
}
items(AppInfo.GUARANTEES) { guarantee ->
Row {
Text("•", color = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(10.dp))
Text(
text = guarantee,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item { Spacer(Modifier.size(16.dp)) }
}
}
@Composable
private fun StackTab(padding: PaddingValues) {
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(AppInfo.STACK) { (layer, tools) ->
Row(verticalAlignment = Alignment.Top) {
Text(
text = layer,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.width(116.dp),
)
Text(
text = tools,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
}
item { Spacer(Modifier.size(16.dp)) }
}
}
@Composable
private fun ChangelogTab(padding: PaddingValues) {
val context = LocalContext.current
val lines = remember { readChangelog(context) }
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
if (lines.isEmpty()) {
item {
Text(
text = "Journal indisponible.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(lines) { line ->
when (line) {
is ChangelogLine.Version -> {
Spacer(Modifier.size(8.dp))
Text(
text = line.text,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
is ChangelogLine.Section -> Text(
text = line.text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.secondary,
)
is ChangelogLine.Bullet -> Row {
Text("•", color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
Text(
text = emphasised(line.text),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
item { Spacer(Modifier.size(16.dp)) }
}
}
/** Le fichier n'emploie que du gras : inutile d'embarquer un analyseur Markdown complet. */
@Composable
private fun emphasised(text: String) = buildAnnotatedString {
var rest = text
while (true) {
val open = rest.indexOf("**")
if (open < 0) break
val close = rest.indexOf("**", open + 2)
if (close < 0) break
append(rest.substring(0, open))
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(rest.substring(open + 2, close))
}
rest = rest.substring(close + 2)
}
append(rest)
}
@Composable
private fun AuthorTab(padding: PaddingValues) {
LazyColumn(
contentPadding = padding,
modifier = Modifier.padding(horizontal = 16.dp),
) {
item {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(52.dp)
.clip(CircleShape),
) {
Surface(color = MaterialTheme.colorScheme.primary, shape = CircleShape) {
Box(Modifier.size(52.dp), contentAlignment = Alignment.Center) {
Text(
text = AppInfo.AUTHOR.take(1).uppercase(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
Spacer(Modifier.width(16.dp))
Column {
Text(AppInfo.AUTHOR, style = MaterialTheme.typography.titleMedium)
Text(
text = AppInfo.AUTHOR_ROLE,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.size(18.dp))
Text("Dépôt", style = MaterialTheme.typography.labelMedium)
Text(
text = AppInfo.REPOSITORY,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.size(18.dp))
Text(
text = AppInfo.AUTHORSHIP,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.size(16.dp))
}
}
}
@@ -0,0 +1,83 @@
package fr.tuxgyver.noxely.ui.about
import fr.tuxgyver.noxely.BuildConfig
/**
* Contenu de la page « À propos », séparé de son rendu.
*
* Tout ce qui est écrit ici se vérifie dans le dépôt : une fonction décrite existe, une garantie
* annoncée est tenue par du code. La version n'est pas recopiée mais lue du manifeste.
*/
object AppInfo {
const val NAME = "Noxely"
const val TAGLINE = "Mettez la musique de côté pendant que vous avez du réseau."
val VERSION: String get() = BuildConfig.VERSION_NAME
const val REPOSITORY = "https://git.tuxtech.fr/tuxgyver/Noxely"
const val AUTHOR = "Johnny"
const val AUTHOR_ROLE = "Conception, direction et supervision"
const val AUTHORSHIP =
"Application développée avec l'assistance de l'IA, sous la direction et la supervision de Johnny."
/** Aucune licence n'est déclarée dans le dépôt : la pastille le signale plutôt que d'en inventer une. */
const val LICENSE = "Licence à définir"
const val LICENSE_DEFINED = false
/** Ce que fait l'application, du point de vue de qui l'utilise. */
val FEATURES = listOf(
"Écouter sans réseau" to
"Ce que vous mettez de côté reste lisible dans le métro, en avion ou forfait épuisé. " +
"Audio et vidéo, avec les commandes au casque et sur l'écran verrouillé.",
"Choisir piste par piste" to
"Un album ou un livre affiche ses pistes avec leur durée et leur taille. Vous cochez " +
"ce que vous emportez, et le bouton annonce le poids exact avant de lancer.",
"Essayer avant de garder" to
"Un appui sur une piste la joue en ligne, sans rien mettre en cache. On décide après.",
"Puiser dans des catalogues libres" to
"Internet Archive, Underground (netlabels et Free Music Archive), LibriVox et " +
"Wikimedia Commons, sans compte ni clé, avec filtrage par langue.",
"Écouter et enregistrer la radio" to
"Les stations les plus écoutées, ce qu'elles diffusent à l'instant, et " +
"l'enregistrement d'un direct — qui prend le nom du morceau annoncé.",
"Guetter un morceau" to
"Vous nommez un titre : l'enregistrement se déclenche dès que la station l'annonce, " +
"et s'arrête à la fin du morceau.",
"Organiser en playlists" to
"Des listes nommées, alimentées d'un appui long, lues à la suite.",
"Rester à jour" to
"L'application surveille les publications du dépôt, télécharge la nouvelle version " +
"en Wi-Fi et propose de l'installer.",
)
/** Chaque promesse est tenue par du code, et dit où. */
val GUARANTEES_INTRO =
"Noxely est un outil personnel : rien n'y transite par un service tiers, et rien ne s'y " +
"télécharge sans un geste de votre part."
val GUARANTEES = listOf(
"Aucun compte, aucune inscription, aucune télémétrie : le projet n'embarque aucune " +
"bibliothèque d'analyse d'audience.",
"Les seules connexions sortantes sont celles des adresses que vous ajoutez et des " +
"catalogues que vous interrogez.",
"Le cache ne contient que ce que vous avez demandé à garder : rien n'y est écrit pendant " +
"une écoute en ligne, et rien n'en est effacé sans votre geste.",
"Le contenu mis en cache est exclu des sauvegardes dans le nuage et des transferts " +
"d'appareil à appareil.",
"Un téléchargement interrompu par une panne de serveur repart seul, trois fois, en " +
"espaçant les tentatives ; sans réseau, il attend son retour.",
"Les catalogues proposés ne présentent que des œuvres libres de droits ou du domaine " +
"public ; les éléments en accès restreint sont écartés avant même d'être affichés.",
"Le jeton qui sert à vérifier les mises à jour est en lecture seule et ne peut rien publier.",
)
/** Couche → outils. Six lignes suffisent. */
val STACK = listOf(
"Interface" to "Jetpack Compose, Material 3, thèmes clair et sombre",
"Lecture" to "Media3 / ExoPlayer, MediaSessionService, notification système",
"Hors connexion" to "Cache Media3 non évinçant, WorkManager pour la reprise",
"Bibliothèque" to "MediaStore pour l'appareil, fichiers JSON pour les imports",
"Réseau" to "HttpURLConnection, kotlinx.serialization, flux ICY pour la radio",
"Publication" to "APK signé, releases Gitea, mise à jour intégrée",
)
}
@@ -0,0 +1,53 @@
package fr.tuxgyver.noxely.ui.about
import android.content.Context
/** Une ligne du journal, telle qu'elle sera rendue. */
sealed interface ChangelogLine {
data class Version(val text: String) : ChangelogLine
data class Section(val text: String) : ChangelogLine
data class Bullet(val text: String) : ChangelogLine
}
/**
* Lit le CHANGELOG livré dans les ressources.
*
* Le fichier est celui du dépôt, recopié dans les assets à la compilation : le journal affiché
* ne peut donc pas diverger de celui que la publication utilise pour ses notes de version.
* Seules les formes réellement employées par le fichier sont interprétées — inutile d'embarquer
* un analyseur Markdown complet pour trois motifs. Le préambule qui précède la première version
* est ignoré : il décrit la convention du fichier, pas l'histoire du logiciel.
*/
fun readChangelog(context: Context, limitVersions: Int = 6): List<ChangelogLine> {
val raw = runCatching {
context.assets.open("CHANGELOG.md").bufferedReader().readText()
}.getOrNull() ?: return emptyList()
val lines = mutableListOf<ChangelogLine>()
var versions = 0
for (line in raw.lineSequence()) {
val trimmed = line.trim()
when {
trimmed.startsWith("## ") -> {
versions++
if (versions > limitVersions) break
lines += ChangelogLine.Version(trimmed.removePrefix("## ").replace("[", "").replace("]", ""))
}
trimmed.startsWith("### ") ->
lines += ChangelogLine.Section(trimmed.removePrefix("### "))
trimmed.startsWith("- ") && versions > 0 ->
lines += ChangelogLine.Bullet(trimmed.removePrefix("- "))
// Une puce peut courir sur plusieurs lignes : on la recolle à la précédente.
trimmed.isNotEmpty() && versions > 0 && lines.lastOrNull() is ChangelogLine.Bullet -> {
val previous = lines.removeAt(lines.lastIndex) as ChangelogLine.Bullet
lines += ChangelogLine.Bullet("${previous.text} $trimmed")
}
}
}
return lines
}
@@ -0,0 +1,233 @@
package fr.tuxgyver.noxely.ui.browse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.sources.CatalogId
import fr.tuxgyver.noxely.sources.CatalogItem
import fr.tuxgyver.noxely.ui.components.Artwork
import fr.tuxgyver.noxely.ui.components.formatBytes
import kotlinx.coroutines.launch
/**
* Recherche dans un catalogue libre de droits.
*
* Un résultat n'est pas un fichier mais un album, un livre ou un morceau : l'ouvrir mène à la
* liste de ses pistes, où l'on écoute et où l'on choisit ce qu'on emporte.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CatalogBrowseScreen(
container: AppContainer,
catalogId: CatalogId,
onBack: () -> Unit,
onOpenItem: (CatalogItem) -> Unit,
contentPadding: PaddingValues,
) {
val catalog = container.catalogs.getValue(catalogId)
val scope = rememberCoroutineScope()
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
var language by rememberSaveable { mutableStateOf<String?>(null) }
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
fun runSearch() {
if (query.isBlank()) return
searching = true
error = null
scope.launch {
catalog.search(query, language).fold(
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
onFailure = { error = it.message ?: "Recherche impossible" },
)
searching = false
}
}
LaunchedEffect(catalogId, language) { runSearch() }
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(catalogId.title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
)
Text(
text = catalogId.license,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = 16.dp),
)
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Rechercher dans ${catalogId.title}") },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
singleLine = true,
shape = MaterialTheme.shapes.large,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
if (catalog.languages.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
FilterChip(
selected = language == null,
onClick = { language = null },
label = { Text("Toutes langues") },
)
catalog.languages.forEach { option ->
FilterChip(
selected = language == option.code,
onClick = { language = option.code },
label = { Text(option.label) },
)
}
}
Spacer(Modifier.size(8.dp))
}
when {
searching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
error != null -> Box(
Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = error!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
else -> LazyColumn(
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(results, key = { it.id }) { item ->
CatalogRow(item = item, onOpen = { onOpenItem(item) })
}
}
}
}
}
@Composable
private fun CatalogRow(item: CatalogItem, onOpen: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp),
) {
Artwork(
uri = item.artworkUri,
title = item.title,
kind = if (item.hint.contains("Vidéo")) MediaKind.VIDEO else MediaKind.AUDIO,
shape = RoundedCornerShape(14.dp),
modifier = Modifier.size(56.dp),
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = listOfNotNull(
item.subtitle.takeIf { it.isNotBlank() },
item.hint.takeIf { it.isNotBlank() },
item.sizeBytes?.let { "~${formatBytes(it)}" },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = "Voir les pistes",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,255 @@
package fr.tuxgyver.noxely.ui.browse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.PlayCircleOutline
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.sources.CatalogId
import fr.tuxgyver.noxely.ui.components.formatBytes
import fr.tuxgyver.noxely.ui.components.formatDuration
import kotlinx.coroutines.launch
/**
* Les pistes d'un résultat de catalogue.
*
* Un album ou un livre en compte souvent des dizaines : chacune s'écoute d'abord en ligne, et
* n'est mise en cache que si on la coche. La taille de la sélection est annoncée avant l'envoi.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CatalogItemScreen(
container: AppContainer,
catalogId: CatalogId,
currentMediaId: String?,
onBack: () -> Unit,
onMessage: (String) -> Unit,
contentPadding: PaddingValues,
) {
val item = container.openedCatalogItem
val scope = rememberCoroutineScope()
var tracks by remember { mutableStateOf<List<MediaEntry>>(emptyList()) }
var selection by remember { mutableStateOf(emptySet<String>()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var adding by remember { mutableStateOf(false) }
LaunchedEffect(item?.id) {
val opened = item ?: run {
onBack()
return@LaunchedEffect
}
loading = true
container.catalogs.getValue(catalogId).tracks(opened).fold(
onSuccess = { tracks = it; error = if (it.isEmpty()) "Aucune piste lisible" else null },
onFailure = { error = it.message ?: "Contenu illisible" },
)
loading = false
}
val selectedSize = remember(selection, tracks) {
tracks.filter { it.id in selection }.sumOf { it.sizeBytes ?: 0L }
}
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = {
Text(
text = item?.title ?: "Pistes",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
actions = {
if (tracks.isNotEmpty()) {
TextButton(onClick = {
selection = if (selection.size == tracks.size) emptySet()
else tracks.mapTo(mutableSetOf()) { it.id }
}) {
Text(if (selection.size == tracks.size) "Aucune" else "Toutes")
}
}
},
)
if (tracks.isNotEmpty()) {
val total = tracks.sumOf { it.sizeBytes ?: 0L }
Text(
text = buildString {
append("${tracks.size} piste(s)")
if (total > 0) append(" · ${formatBytes(total)} au total")
append(" · appuyez pour écouter en ligne")
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
error != null -> Box(
Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = error!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
else -> LazyColumn(
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(tracks, key = { it.id }) { track ->
TrackRow(
track = track,
playing = track.id == currentMediaId,
selected = track.id in selection,
onPreview = {
// Un aperçu ne rejoint pas la bibliothèque : on le confie au
// registre transitoire pour que la lecture sache le résoudre.
container.library.preview(tracks)
container.player.play(tracks, tracks.indexOf(track))
},
onToggle = {
selection = if (track.id in selection) selection - track.id
else selection + track.id
},
)
}
item { Spacer(Modifier.size(88.dp)) }
}
}
}
SelectionBar(
count = selection.size,
actionLabel = if (selectedSize > 0) "Garder ${formatBytes(selectedSize)}" else "Garder",
busy = adding,
onClear = { selection = emptySet() },
onConfirm = {
adding = true
scope.launch {
val chosen = tracks.filter { it.id in selection }
val added = container.imported.add(chosen)
chosen.forEach(container.downloads::download)
adding = false
selection = emptySet()
onMessage(
if (added == 0) "Ces pistes sont déjà dans la bibliothèque"
else "$added piste(s) ajoutées, téléchargement lancé"
)
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(contentPadding),
)
}
}
@Composable
private fun TrackRow(
track: MediaEntry,
playing: Boolean,
selected: Boolean,
onPreview: () -> Unit,
onToggle: () -> Unit,
) {
Surface(
color = when {
playing -> MaterialTheme.colorScheme.primaryContainer
selected -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surface
},
shape = MaterialTheme.shapes.small,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onPreview),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Icon(
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
contentDescription = if (playing) "En lecture" else "Écouter en ligne",
tint = if (playing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = listOfNotNull(
track.durationMs.takeIf { it > 0 }?.let(::formatDuration),
track.sizeBytes?.takeIf { it > 0 }?.let(::formatBytes),
).joinToString(" · ").ifBlank { "Taille inconnue" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Checkbox(checked = selected, onCheckedChange = { onToggle() })
}
}
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.browse
package fr.tuxgyver.noxely.ui.browse
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.browse
package fr.tuxgyver.noxely.ui.browse
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -35,10 +35,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.ui.components.MediaRow
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.ui.components.MediaRow
/**
* Parcours d'une source déjà présente dans la bibliothèque : on y choisit, un par un ou en lot,
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.components
package fr.tuxgyver.noxely.ui.components
import android.graphics.Bitmap
import android.os.Build
@@ -28,7 +28,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import coil.compose.AsyncImage
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.noxely.model.MediaKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.components
package fr.tuxgyver.noxely.ui.components
import java.util.Locale
@@ -1,7 +1,8 @@
package fr.tuxgyver.cachette.ui.components
package fr.tuxgyver.noxely.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,9 +32,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.offline.OfflineState
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.offline.OfflineState
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MediaRow(
entry: MediaEntry,
@@ -45,6 +47,8 @@ fun MediaRow(
modifier: Modifier = Modifier,
/** Non nul en mode sélection : la ligne porte alors une case à cocher au lieu de son action. */
selected: Boolean? = null,
/** Appui long : ajout à une playlist. Absent là où le geste n'a pas de sens. */
onLongClick: (() -> Unit)? = null,
) {
val background by animateColorAsState(
targetValue = when {
@@ -60,7 +64,7 @@ fun MediaRow(
shape = MaterialTheme.shapes.medium,
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.library
package fr.tuxgyver.noxely.ui.library
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -40,12 +40,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.library.LocalMediaScanner
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.offline.OfflineState
import fr.tuxgyver.cachette.ui.components.MediaRow
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.library.LocalMediaScanner
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.offline.OfflineState
import fr.tuxgyver.noxely.ui.components.MediaRow
private enum class LibraryFilter(val label: String) {
ALL("Tout"),
@@ -59,6 +59,7 @@ fun LibraryScreen(
container: AppContainer,
currentMediaId: String?,
onPlay: (List<MediaEntry>, Int) -> Unit,
onAddToPlaylist: (MediaEntry) -> Unit,
contentPadding: PaddingValues,
) {
val entries by container.library.entries.collectAsStateWithLifecycle()
@@ -169,6 +170,7 @@ fun LibraryScreen(
onClick = { onPlay(visible, visible.indexOf(entry)) },
onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) },
onLongClick = { onAddToPlaylist(entry) },
)
}
}
@@ -199,7 +201,7 @@ private fun PermissionCard(onGrant: () -> Unit) {
Text("Accéder à vos médias", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.size(6.dp))
Text(
"Cachette lit la musique et les vidéos déjà présentes sur l'appareil. Rien n'est envoyé ailleurs.",
"Noxely lit la musique et les vidéos déjà présentes sur l'appareil. Rien n'est envoyé ailleurs.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.size(14.dp))
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.offline
package fr.tuxgyver.noxely.ui.offline
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -14,8 +14,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -27,17 +29,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.ui.components.MediaRow
import fr.tuxgyver.cachette.ui.components.formatBytes
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.radio.RecordingSink
import fr.tuxgyver.noxely.ui.components.MediaRow
import fr.tuxgyver.noxely.ui.components.formatBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
@@ -45,24 +52,36 @@ fun DownloadsScreen(
container: AppContainer,
currentMediaId: String?,
onPlay: (List<MediaEntry>, Int) -> Unit,
onAddToPlaylist: (MediaEntry) -> Unit,
contentPadding: PaddingValues,
) {
val entries by container.library.entries.collectAsStateWithLifecycle()
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
var confirmClear by remember { mutableStateOf(false) }
var pendingRecordingDelete by remember { mutableStateOf<MediaEntry?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var cacheSize by remember { mutableLongStateOf(0L) }
LaunchedEffect(offlineStates) {
cacheSize = withContext(Dispatchers.IO) { container.offline.cacheSizeBytes() }
}
// Un enregistrement radio n'a pas d'état de téléchargement : le fichier est déjà là.
// L'écran annoncerait sinon « disponibles sans réseau » en omettant ce qui l'est le plus.
val cached = remember(entries, offlineStates) {
entries.filter { offlineStates[it.id] != null }
entries.filter { offlineStates[it.id] != null || it.source == SourceKind.RECORDING }
.sortedByDescending { offlineStates[it.id]?.isActive == true }
}
val completed = cached.count { offlineStates[it.id]?.isComplete == true }
val recordings = cached.filter { it.source == SourceKind.RECORDING }
val completed = cached.count {
offlineStates[it.id]?.isComplete == true || it.source == SourceKind.RECORDING
}
val running = cached.count { offlineStates[it.id]?.isActive == true }
LaunchedEffect(offlineStates, recordings) {
cacheSize = withContext(Dispatchers.IO) {
container.offline.cacheSizeBytes() + recordings.sumOf { it.sizeBytes ?: 0L }
}
}
LazyColumn(
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
@@ -91,6 +110,8 @@ fun DownloadsScreen(
items(cached, key = { it.id }) { entry ->
val state = offlineStates[entry.id]
Column {
val isRecording = entry.source == SourceKind.RECORDING
Row(verticalAlignment = Alignment.CenterVertically) {
MediaRow(
entry = entry,
offline = state,
@@ -98,7 +119,18 @@ fun DownloadsScreen(
onClick = { onPlay(cached, cached.indexOf(entry)) },
onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) },
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) {
LinearProgressIndicator(
progress = { (state.percent / 100f).coerceIn(0f, 1f) },
@@ -111,12 +143,32 @@ 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) {
AlertDialog(
onDismissRequest = { confirmClear = false },
title = { Text("Vider la cachette ?") },
text = {
Text("Tout le contenu mis de côté sera supprimé de l'appareil (${formatBytes(cacheSize)}). Les fichiers déjà présents sur l'appareil ne sont pas touchés.")
Text("Le contenu téléchargé sera supprimé de l'appareil. Les fichiers déjà présents et vos enregistrements radio ne sont pas touchés.")
},
confirmButton = {
TextButton(onClick = {
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.player
package fr.tuxgyver.noxely.ui.player
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
@@ -26,8 +26,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.tuxgyver.cachette.playback.PlayerUiState
import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.noxely.playback.PlayerUiState
import fr.tuxgyver.noxely.ui.components.Artwork
@Composable
fun MiniPlayer(
@@ -1,7 +1,8 @@
package fr.tuxgyver.cachette.ui.player
package fr.tuxgyver.noxely.ui.player
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,11 +20,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Forward30
import androidx.compose.material.icons.rounded.FastForward
import androidx.compose.material.icons.rounded.FastRewind
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlaylistAdd
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Replay10
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious
import androidx.compose.material3.CircularProgressIndicator
@@ -51,12 +54,12 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import fr.tuxgyver.cachette.model.MediaEntry
import fr.tuxgyver.cachette.model.MediaKind
import fr.tuxgyver.cachette.offline.OfflineState
import fr.tuxgyver.cachette.playback.PlayerUiState
import fr.tuxgyver.cachette.ui.components.Artwork
import fr.tuxgyver.cachette.ui.components.formatDuration
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.offline.OfflineState
import fr.tuxgyver.noxely.playback.PlayerUiState
import fr.tuxgyver.noxely.ui.components.Artwork
import fr.tuxgyver.noxely.ui.components.formatDuration
@Composable
fun NowPlayingScreen(
@@ -71,6 +74,8 @@ fun NowPlayingScreen(
onSkipBy: (Long) -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onStop: () -> Unit,
onAddToPlaylist: () -> Unit,
onDownload: () -> Unit,
onRemoveOffline: () -> Unit,
) {
@@ -112,6 +117,12 @@ fun NowPlayingScreen(
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onAddToPlaylist) {
Icon(Icons.Rounded.PlaylistAdd, contentDescription = "Ajouter à une playlist")
}
IconButton(onClick = onStop) {
Icon(Icons.Rounded.Stop, contentDescription = "Arrêter la lecture")
}
OfflineToggle(entry, offline, onDownload, onRemoveOffline)
}
@@ -190,9 +201,12 @@ fun NowPlayingScreen(
IconButton(onClick = onPrevious, enabled = state.hasPrevious) {
Icon(Icons.Rounded.SkipPrevious, contentDescription = "Précédent")
}
IconButton(onClick = { onSkipBy(-10_000) }) {
Icon(Icons.Rounded.Replay10, contentDescription = "Reculer de 10 secondes")
}
SkipButton(
icon = Icons.Rounded.FastRewind,
label = "15 s",
description = "Reculer de 15 secondes",
onClick = { onSkipBy(-15_000) },
)
FilledIconButton(
onClick = onTogglePlay,
shape = CircleShape,
@@ -213,9 +227,12 @@ fun NowPlayingScreen(
)
}
}
IconButton(onClick = { onSkipBy(30_000) }) {
Icon(Icons.Rounded.Forward30, contentDescription = "Avancer de 30 secondes")
}
SkipButton(
icon = Icons.Rounded.FastForward,
label = "15 s",
description = "Avancer de 15 secondes",
onClick = { onSkipBy(15_000) },
)
IconButton(onClick = onNext, enabled = state.hasNext) {
Icon(Icons.Rounded.SkipNext, contentDescription = "Suivant")
}
@@ -226,6 +243,27 @@ fun NowPlayingScreen(
}
}
/** Material ne fournit pas d'icône « 15 » : le pas de saut est donc écrit sous l'icône. */
@Composable
private fun SkipButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
onClick: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 10.dp, vertical = 6.dp),
) {
Icon(icon, contentDescription = description)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun OfflineToggle(
entry: MediaEntry?,
@@ -0,0 +1,138 @@
package fr.tuxgyver.noxely.ui.playlists
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
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.rounded.Add
import androidx.compose.material.icons.rounded.PlaylistPlay
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import kotlinx.coroutines.launch
/** Choix de la playlist d'accueil, avec création à la volée si aucune ne convient. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddToPlaylistSheet(
container: AppContainer,
entry: MediaEntry,
onDismiss: () -> Unit,
onMessage: (String) -> Unit,
) {
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()
var newName by remember { mutableStateOf("") }
fun addTo(playlistId: String, name: String) {
scope.launch {
container.playlists.add(playlistId, listOf(entry.id))
onMessage("« ${entry.title} » ajouté à $name")
}
onDismiss()
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
Modifier
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
.navigationBarsPadding(),
) {
Text("Ajouter à une playlist", style = MaterialTheme.typography.headlineSmall)
Text(
text = entry.title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.size(16.dp))
if (playlists.isNotEmpty()) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.heightIn(max = 280.dp),
) {
items(playlists, key = { it.id }) { playlist ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { addTo(playlist.id, playlist.name) }
.padding(vertical = 12.dp),
) {
Icon(Icons.Rounded.PlaylistPlay, contentDescription = null)
Spacer(Modifier.width(14.dp))
Column {
Text(playlist.name, style = MaterialTheme.typography.bodyLarge)
Text(
text = "${playlist.entryIds.size} titre(s)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(Modifier.size(12.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
placeholder = { Text("Nouvelle playlist") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
TextButton(
enabled = newName.isNotBlank(),
onClick = {
scope.launch {
val created = container.playlists.create(newName)
container.playlists.add(created.id, listOf(entry.id))
onMessage("« ${entry.title} » ajouté à ${created.name}")
}
onDismiss()
},
) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(6.dp))
Text("Créer")
}
}
}
}
}
@@ -0,0 +1,136 @@
package fr.tuxgyver.noxely.ui.playlists
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.RemoveCircleOutline
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.ui.components.MediaRow
import kotlinx.coroutines.launch
/** Le contenu d'une playlist, dans l'ordre d'ajout. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistDetailScreen(
container: AppContainer,
playlistId: String,
currentMediaId: String?,
onBack: () -> Unit,
onPlay: (List<MediaEntry>, Int) -> Unit,
contentPadding: PaddingValues,
) {
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
val library by container.library.entries.collectAsStateWithLifecycle()
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val playlist = playlists.firstOrNull { it.id == playlistId }
// Un identifiant sans média correspondant est ignoré : la bibliothèque reste la référence.
val tracks = remember(playlist, library) {
playlist?.entryIds?.mapNotNull { id -> library.firstOrNull { it.id == id } }.orEmpty()
}
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = {
Text(
text = playlist?.name ?: "Playlist",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
)
if (tracks.isNotEmpty()) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
Button(onClick = { onPlay(tracks, 0) }) {
Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text("Tout lire")
}
}
}
if (tracks.isEmpty()) {
Box(
Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Cette playlist est vide. Appuyez longuement sur un média de la bibliothèque pour l'y ajouter.",
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(tracks, key = { it.id }) { entry ->
Row(verticalAlignment = Alignment.CenterVertically) {
MediaRow(
entry = entry,
offline = offlineStates[entry.id],
isCurrent = entry.id == currentMediaId,
onClick = { onPlay(tracks, tracks.indexOf(entry)) },
onDownload = { container.downloads.download(entry) },
onRemoveOffline = { container.downloads.remove(entry.id) },
modifier = Modifier.weight(1f),
)
IconButton(onClick = {
scope.launch { container.playlists.remove(playlistId, entry.id) }
}) {
Icon(
Icons.Rounded.RemoveCircleOutline,
contentDescription = "Retirer de la playlist",
)
}
}
}
}
}
}
}
@@ -0,0 +1,191 @@
package fr.tuxgyver.noxely.ui.playlists
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.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.PlaylistPlay
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import kotlinx.coroutines.launch
@Composable
fun PlaylistsScreen(
container: AppContainer,
onOpen: (String) -> Unit,
contentPadding: PaddingValues,
) {
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var creating by remember { mutableStateOf(false) }
var pendingDelete by remember { mutableStateOf<String?>(null) }
LazyColumn(
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
) {
item {
Column(Modifier.padding(top = 12.dp)) {
Text("Playlists", style = MaterialTheme.typography.displaySmall)
Text(
text = "${playlists.size} liste(s)",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(14.dp))
Button(onClick = { creating = true }) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text("Nouvelle playlist")
}
Spacer(Modifier.size(14.dp))
}
}
if (playlists.isEmpty()) {
item {
Box(
Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Rounded.PlaylistPlay,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.size(12.dp))
Text("Aucune playlist", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.size(6.dp))
Text(
text = "Appuyez longuement sur un média de la bibliothèque pour l'ajouter à une liste.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
items(playlists, key = { it.id }) { playlist ->
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
tonalElevation = 1.dp,
modifier = Modifier
.fillMaxWidth()
.clickable { onOpen(playlist.id) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp),
) {
Icon(
Icons.Rounded.PlaylistPlay,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = playlist.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${playlist.entryIds.size} titre(s)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { pendingDelete = playlist.id }) {
Icon(Icons.Rounded.Delete, contentDescription = "Supprimer la playlist")
}
}
}
}
}
if (creating) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { creating = false },
title = { Text("Nouvelle playlist") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
placeholder = { Text("Nom de la liste") },
singleLine = true,
)
},
confirmButton = {
TextButton(
enabled = name.isNotBlank(),
onClick = {
scope.launch { container.playlists.create(name) }
creating = false
},
) { Text("Créer") }
},
dismissButton = { TextButton(onClick = { creating = false }) { Text("Annuler") } },
)
}
pendingDelete?.let { id ->
val name = container.playlists.playlist(id)?.name.orEmpty()
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Supprimer « $name » ?") },
text = { Text("La liste disparaît. Les médias et les fichiers mis en cache restent.") },
confirmButton = {
TextButton(onClick = {
scope.launch { container.playlists.delete(id) }
pendingDelete = null
}) { Text("Supprimer") }
},
dismissButton = { TextButton(onClick = { pendingDelete = null }) { Text("Annuler") } },
)
}
}
@@ -0,0 +1,439 @@
package fr.tuxgyver.noxely.ui.radio
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.FiberManualRecord
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.PlayCircleOutline
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.radio.RADIO_GENRES
import fr.tuxgyver.noxely.radio.RadioStation
import fr.tuxgyver.noxely.radio.Recorder
import fr.tuxgyver.noxely.radio.RecordingService
import fr.tuxgyver.noxely.ui.components.Artwork
import fr.tuxgyver.noxely.ui.components.formatBytes
import fr.tuxgyver.noxely.ui.components.formatDuration
import kotlinx.coroutines.delay
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
/**
* Les stations les plus écoutées, à écouter en direct ou à enregistrer.
*
* L'enregistrement produit un fichier local qui rejoint la bibliothèque : il n'a donc rien à
* télécharger ensuite, il est déjà hors connexion.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RadioScreen(
container: AppContainer,
currentMediaId: String?,
onBack: () -> Unit,
onMessage: (String) -> Unit,
contentPadding: PaddingValues,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val status by Recorder.status.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var genre by rememberSaveable { mutableStateOf<String?>(null) }
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var nowPlaying by remember { mutableStateOf(emptyMap<String, String>()) }
var scheduleFor by remember { mutableStateOf<RadioStation?>(null) }
fun load() {
loading = true
error = null
scope.launch {
val outcome = if (query.isBlank()) container.radio.popular(genre)
else container.radio.search(query)
outcome.fold(
onSuccess = { stations = it; if (it.isEmpty()) error = "Aucune station" },
onFailure = { error = it.message ?: "Annuaire indisponible" },
)
loading = false
}
}
LaunchedEffect(genre) { load() }
// Aucun annuaire n'expose le morceau en cours : il faut ouvrir chaque flux. On s'en tient
// donc aux premières stations affichées, quatre à la fois, et le résultat est conservé.
LaunchedEffect(stations) {
nowPlaying = emptyMap()
stations.take(PROBED_STATIONS).chunked(4).forEach { group ->
val found = group.map { station ->
async { station.id to container.radio.nowPlaying(station.streamUrl) }
}.awaitAll()
nowPlaying = nowPlaying + found.mapNotNull { (id, title) ->
title?.let { id to it }
}
}
}
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Radio") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
},
)
if (status.isRecording) {
// La progression n'est publiée que tous les 256 Ko : le chronomètre se rafraîchit
// donc de lui-même, sinon il semblerait figé entre deux paliers.
var tick by remember { mutableStateOf(0L) }
LaunchedEffect(status.startedAt) {
while (true) {
tick = System.currentTimeMillis()
delay(1_000)
}
}
RecordingBanner(
station = status.station!!,
title = status.currentTitle,
waitingFor = status.waitingFor,
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
bytes = status.bytes,
onStop = {
RecordingService.stop(context)
onMessage("Enregistrement versé dans la bibliothèque")
},
)
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Rechercher une station") },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
singleLine = true,
shape = MaterialTheme.shapes.large,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { load() }),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
FilterChip(
selected = genre == null,
onClick = { genre = null; query = "" },
label = { Text("Les plus écoutées") },
)
RADIO_GENRES.forEach { option ->
FilterChip(
selected = genre == option,
onClick = { genre = option; query = "" },
label = { Text(option.replaceFirstChar(Char::uppercase)) },
)
}
}
Spacer(Modifier.size(8.dp))
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
error != null -> Box(
Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = error!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
else -> LazyColumn(
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
items(stations, key = { it.id }) { station ->
StationRow(
station = station,
nowPlaying = nowPlaying[station.id],
playing = station.asEntry().id == currentMediaId,
recording = status.station?.id == station.id,
onSchedule = { scheduleFor = station },
onListen = {
val entry = station.asEntry()
container.library.preview(listOf(entry))
container.player.play(listOf(entry), 0)
},
onRecord = {
if (status.isRecording) {
onMessage("Un enregistrement est déjà en cours")
} else {
RecordingService.start(context, station)
onMessage("Enregistrement de ${station.name} démarré")
}
},
)
}
item { Spacer(Modifier.size(24.dp)) }
}
}
}
scheduleFor?.let { station ->
ScheduleDialog(
station = station,
onDismiss = { scheduleFor = null },
onConfirm = { wanted ->
RecordingService.start(context, station, whenTitleContains = wanted)
onMessage("Noxely guette « $wanted » sur ${station.name}")
scheduleFor = null
},
)
}
}
/** Au-delà, le nombre de connexions simultanées ne se justifierait plus. */
private const val PROBED_STATIONS = 12
/** Une station écoutée en direct est un média éphémère : pas de durée, pas de mise en cache. */
private fun RadioStation.asEntry() = MediaEntry(
id = "radio:$id",
source = SourceKind.URL,
kind = MediaKind.AUDIO,
title = name,
subtitle = subtitle.ifBlank { "Radio en direct" },
uri = streamUrl,
artworkUri = faviconUrl,
)
/** Programme un enregistrement : il démarrera dès que la station annoncera ce titre. */
@Composable
private fun ScheduleDialog(
station: RadioStation,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var wanted by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Guetter un titre") },
text = {
Column {
Text(
text = "Noxely écoutera ${station.name} et déclenchera l'enregistrement dès " +
"qu'un titre contenant ce texte sera annoncé, puis s'arrêtera à la fin du morceau.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = wanted,
onValueChange = { wanted = it },
placeholder = { Text("Titre ou artiste") },
singleLine = true,
)
}
},
confirmButton = {
TextButton(enabled = wanted.isNotBlank(), onClick = { onConfirm(wanted.trim()) }) {
Text("Guetter")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
)
}
@Composable
private fun RecordingBanner(
station: RadioStation,
title: String?,
waitingFor: String?,
elapsedMs: Long,
bytes: Long,
onStop: () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp),
) {
Icon(
Icons.Rounded.FiberManualRecord,
contentDescription = null,
tint = Color(0xFFD32F2F),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = waitingFor?.let { "En attente de « $it »" } ?: title ?: station.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = listOfNotNull(
title?.let { station.name },
formatDuration(elapsedMs),
formatBytes(bytes),
).joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Button(onClick = onStop) {
Icon(Icons.Rounded.Stop, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(6.dp))
Text("Arrêter")
}
}
}
}
@Composable
private fun StationRow(
station: RadioStation,
nowPlaying: String?,
playing: Boolean,
recording: Boolean,
onListen: () -> Unit,
onRecord: () -> Unit,
onSchedule: () -> Unit,
) {
Surface(
color = if (playing) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onListen),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp),
) {
Artwork(
uri = station.faviconUrl,
title = station.name,
kind = MediaKind.AUDIO,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = station.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = nowPlaying ?: station.subtitle,
style = MaterialTheme.typography.bodySmall,
color = if (nowPlaying != null) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
contentDescription = if (playing) "En écoute" else "Écouter en direct",
tint = if (playing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(onClick = onSchedule) {
Icon(
Icons.Rounded.Alarm,
contentDescription = "Enregistrer quand un titre passe",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRecord) {
Icon(
Icons.Rounded.FiberManualRecord,
contentDescription = "Enregistrer cette station",
tint = if (recording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.sources
package fr.tuxgyver.noxely.ui.sources
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -16,7 +16,11 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.MenuBook
import androidx.compose.material.icons.rounded.Headphones
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.LibraryMusic
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Radio
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Link
@@ -46,9 +50,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.cachette.AppContainer
import fr.tuxgyver.cachette.model.SourceKind
import fr.tuxgyver.cachette.sources.CatalogId
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.sources.CatalogId
import kotlinx.coroutines.launch
@Composable
@@ -58,6 +62,8 @@ fun SourcesScreen(
onMessage: (String) -> Unit,
onBrowseSource: (SourceKind) -> Unit,
onBrowseCatalog: (CatalogId) -> Unit,
onBrowseRadio: () -> Unit,
onOpenAbout: () -> Unit,
) {
val entries by container.library.entries.collectAsStateWithLifecycle()
var sheet by remember { mutableStateOf<ImportKind?>(null) }
@@ -121,6 +127,19 @@ fun SourcesScreen(
)
}
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)
@@ -133,7 +152,12 @@ fun SourcesScreen(
items(CatalogId.entries) { catalog ->
SourceCard(
icon = if (catalog == CatalogId.LIBRIVOX) Icons.AutoMirrored.Rounded.MenuBook else Icons.Rounded.Public,
icon = when (catalog) {
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
CatalogId.UNDERGROUND -> Icons.Rounded.Headphones
else -> Icons.Rounded.Public
},
title = catalog.title,
status = catalog.license,
description = catalog.description,
@@ -149,6 +173,23 @@ fun SourcesScreen(
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
}
item {
Spacer(Modifier.size(8.dp))
UpdateCard(container)
}
item {
SourceCard(
icon = Icons.Rounded.Info,
title = "À propos",
status = "Version ${fr.tuxgyver.noxely.BuildConfig.VERSION_NAME}",
description = "Ce que fait Noxely, ce qu'elle garantit, avec quoi elle est faite et qui en répond.",
actionLabel = "Ouvrir",
onAction = onOpenAbout,
onOpen = onOpenAbout,
)
}
unavailableSourceCards(
listOf(
UnavailableSource(
@@ -0,0 +1,137 @@
package fr.tuxgyver.noxely.ui.sources
import androidx.compose.foundation.layout.Column
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.SystemUpdate
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.BuildConfig
import fr.tuxgyver.noxely.update.UpdateInfo
import fr.tuxgyver.noxely.update.UpdateNotifier
import kotlinx.coroutines.launch
import java.io.File
private sealed interface UpdateUiState {
data object Idle : UpdateUiState
data object Checking : UpdateUiState
data object UpToDate : UpdateUiState
data class Available(val info: UpdateInfo) : UpdateUiState
data class Downloading(val info: UpdateInfo) : UpdateUiState
data class Ready(val info: UpdateInfo, val apk: File) : UpdateUiState
data class Failed(val message: String) : UpdateUiState
}
/**
* Vérification manuelle des mises à jour.
*
* La vérification automatique tourne une fois par jour en tâche de fond ; cette carte donne le
* même résultat à la demande, et rend visible la version installée.
*/
@Composable
fun UpdateCard(container: AppContainer) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<UpdateUiState>(UpdateUiState.Idle) }
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Rounded.SystemUpdate,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(12.dp))
Column(Modifier.weight(1f)) {
Text("Mises à jour", style = MaterialTheme.typography.titleMedium)
Text(
text = "Version installée ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.size(10.dp))
val current = state
Text(
text = when (current) {
UpdateUiState.Idle ->
"Noxely regarde chaque jour si une nouvelle version a été publiée, et la télécharge en Wi-Fi."
UpdateUiState.Checking -> "Vérification en cours…"
UpdateUiState.UpToDate -> "Vous avez déjà la dernière version."
is UpdateUiState.Available -> "Version ${current.info.version} disponible."
is UpdateUiState.Downloading -> "Téléchargement de la version ${current.info.version}…"
is UpdateUiState.Ready -> "Version ${current.info.version} téléchargée, prête à installer."
is UpdateUiState.Failed -> current.message
},
style = MaterialTheme.typography.bodyMedium,
color = if (current is UpdateUiState.Failed) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(12.dp))
when (current) {
is UpdateUiState.Ready -> Button(
onClick = { UpdateNotifier.install(context, current.apk) }
) { Text("Installer") }
is UpdateUiState.Available -> Button(onClick = {
state = UpdateUiState.Downloading(current.info)
scope.launch {
runCatching { container.updates.downloadApk(current.info) }.fold(
onSuccess = { state = UpdateUiState.Ready(current.info, it) },
onFailure = {
state = UpdateUiState.Failed(it.message ?: "Téléchargement impossible")
},
)
}
}) { Text("Télécharger") }
UpdateUiState.Checking, is UpdateUiState.Downloading ->
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
else -> Button(onClick = {
state = UpdateUiState.Checking
scope.launch {
runCatching { container.updates.checkForUpdate() }.fold(
onSuccess = { info ->
state = if (info == null) UpdateUiState.UpToDate
else UpdateUiState.Available(info)
},
onFailure = {
state = UpdateUiState.Failed(it.message ?: "Vérification impossible")
},
)
}
}) { Text("Rechercher une mise à jour") }
}
}
}
}
@@ -1,4 +1,4 @@
package fr.tuxgyver.cachette.ui.theme
package fr.tuxgyver.noxely.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -57,7 +57,7 @@ private val DarkColors = darkColorScheme(
outlineVariant = Color(0xFF3A3048),
)
private val CachetteShapes = Shapes(
private val NoxelyShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(14.dp),
medium = RoundedCornerShape(20.dp),
@@ -65,7 +65,7 @@ private val CachetteShapes = Shapes(
extraLarge = RoundedCornerShape(36.dp),
)
private val CachetteTypography = Typography().run {
private val NoxelyTypography = Typography().run {
copy(
displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp),
headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.4).sp),
@@ -77,7 +77,7 @@ private val CachetteTypography = Typography().run {
}
@Composable
fun CachetteTheme(
fun NoxelyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
@@ -87,8 +87,8 @@ fun CachetteTheme(
val colors = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colors,
shapes = CachetteShapes,
typography = CachetteTypography,
shapes = NoxelyShapes,
typography = NoxelyTypography,
content = content,
)
}
@@ -0,0 +1,53 @@
package fr.tuxgyver.noxely.update
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
/**
* Vérifie chaque jour la dernière release du dépôt, télécharge l'APK si une version plus récente
* existe, puis notifie. L'installation reste un geste de l'utilisateur, imposé par Android.
*/
class UpdateCheckWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val repository = (applicationContext as NoxelyApp).container.updates
val update = runCatching { repository.checkForUpdate() }.getOrNull()
?: return Result.success()
val apk = runCatching { repository.downloadApk(update) }.getOrNull()
?: return Result.retry()
UpdateNotifier.notifyReadyToInstall(applicationContext, update.version, apk)
return Result.success()
}
companion object {
private const val WORK_NAME = "cachette-update-check"
/** Une vérification par jour, sur réseau non facturé à l'usage. */
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
}
}
@@ -0,0 +1,79 @@
package fr.tuxgyver.noxely.update
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import fr.tuxgyver.noxely.R
import java.io.File
/** Prévient que la mise à jour est téléchargée et ouvre l'installateur système d'un appui. */
object UpdateNotifier {
private const val CHANNEL_ID = "cachette_updates"
private const val NOTIFICATION_ID = 4301
fun notifyReadyToInstall(context: Context, version: String, apk: File) {
createChannel(context)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pending = PendingIntent.getActivity(
context,
0,
install,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_offline)
.setContentTitle("Noxely $version est prête")
.setContentText("Appuyez pour installer la mise à jour.")
.setContentIntent(pending)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
// Sans la permission de notifier, le téléchargement reste valable : l'écran Sources
// propose la même installation. On s'abstient donc plutôt que de planter.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)
}
/** Ouvre l'installateur système pour un APK déjà téléchargé. */
fun install(context: Context, apk: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"Mises à jour",
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = "Disponibilité d'une nouvelle version de Noxely" }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
@@ -0,0 +1,121 @@
package fr.tuxgyver.noxely.update
import android.content.Context
import fr.tuxgyver.noxely.BuildConfig
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.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
data class UpdateInfo(
val version: String,
val notes: String,
val downloadUrl: String,
val apkFileName: String,
)
/**
* Vérifie la dernière release publiée sur le dépôt et en télécharge l'APK.
*
* Le dépôt étant privé, chaque appel est authentifié par un jeton en lecture seule injecté à la
* compilation. L'installation elle-même reste un geste de l'utilisateur : Android l'impose à
* toute application distribuée hors magasin.
*/
class UpdateRepository(
private val context: Context,
private val currentVersion: String,
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) {
if (BuildConfig.GITEA_UPDATE_TOKEN.isBlank()) return@withContext null
val body = runCatching { fetch("${BuildConfig.UPDATE_API}/releases/latest") }.getOrNull()
?: return@withContext null
val release = json.parseToJsonElement(body).jsonObject
val remote = release["tag_name"]?.jsonPrimitive?.content?.removePrefix("v")
?: return@withContext null
if (!isNewer(remote, currentVersion)) return@withContext null
val apk = (release["assets"] as? JsonArray)
?.mapNotNull { it as? JsonObject }
?.firstOrNull { it["name"]?.jsonPrimitive?.content?.endsWith(".apk") == true }
?: return@withContext null
UpdateInfo(
version = remote,
notes = release["body"]?.jsonPrimitive?.content.orEmpty(),
downloadUrl = apk["browser_download_url"]?.jsonPrimitive?.content ?: return@withContext null,
apkFileName = apk["name"]?.jsonPrimitive?.content ?: "cachette-$remote.apk",
)
}
/** Télécharge l'APK dans le cache de l'application et renvoie le fichier prêt à installer. */
suspend fun downloadApk(update: UpdateInfo): File = withContext(Dispatchers.IO) {
val directory = File(context.cacheDir, "updates").apply { mkdirs() }
// Une version précédemment téléchargée n'a plus lieu d'être une fois la suivante demandée.
directory.listFiles()?.forEach { if (it.name != update.apkFileName) it.delete() }
val target = File(directory, update.apkFileName)
val partial = File(directory, "${update.apkFileName}.part")
val connection = authenticated(update.downloadUrl)
try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Téléchargement refusé (${connection.responseCode})")
}
connection.inputStream.use { input ->
FileOutputStream(partial).use { output -> input.copyTo(output) }
}
} finally {
connection.disconnect()
}
// Renommage final : un fichier .apk présent est donc toujours un fichier complet.
if (target.exists()) target.delete()
check(partial.renameTo(target)) { "Impossible de finaliser le téléchargement" }
target
}
private fun fetch(url: String): String {
val connection = authenticated(url)
return try {
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Le dépôt a répondu ${connection.responseCode}")
}
connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
}
private fun authenticated(url: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
instanceFollowRedirects = true
connectTimeout = 15_000
readTimeout = 60_000
setRequestProperty("User-Agent", "Noxely/$currentVersion (Android)")
setRequestProperty("Authorization", "token ${BuildConfig.GITEA_UPDATE_TOKEN}")
}
/** Comparaison numérique champ à champ : « 1.10.0 » est plus récent que « 1.9.0 ». */
private fun isNewer(remote: String, current: String): Boolean {
val r = remote.split('.').mapNotNull { it.toIntOrNull() }
val c = current.split('.').mapNotNull { it.toIntOrNull() }
for (i in 0 until maxOf(r.size, c.size)) {
val remotePart = r.getOrElse(i) { 0 }
val currentPart = c.getOrElse(i) { 0 }
if (remotePart != currentPart) return remotePart > currentPart
}
return false
}
}
@@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Note de musique posée sur une flèche descendante : « mis de côté, hors ligne ». -->
<path
android:fillColor="#FFB79CFF"
android:pathData="M64,28 L64,58.5 a10,10 0 1,1 -6,-9.2 L58,37.5 L44,40.6 L44,64 a10,10 0 1,1 -6,-9.2 L38,35.8 L64,28 Z" />
<path
android:fillColor="#FF7CE7D6"
android:pathData="M54,70 L54,80 L47,80 L54,88 L61,80 L54,80 Z" />
</vector>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<resources>
<style name="Theme.Cachette" parent="android:Theme.Material.NoActionBar">
<style name="Theme.Noxely" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
+1 -1
View File
@@ -1,4 +1,4 @@
<resources>
<color name="splash_background">#FFF7F2FA</color>
<color name="ic_launcher_background">#FF1B1033</color>
<color name="ic_launcher_background">#FF0E9CC7</color>
</resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<resources>
<string name="app_name">Cachette</string>
<string name="app_name">Noxely</string>
<string name="download_channel_name">Téléchargements</string>
</resources>
+1 -1
View File
@@ -1,5 +1,5 @@
<resources>
<style name="Theme.Cachette" parent="android:Theme.Material.Light.NoActionBar">
<style name="Theme.Noxely" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Seul le dossier des mises à jour est partagé, et uniquement avec l'installateur système. -->
<paths>
<cache-path name="updates" path="updates/" />
</paths>
+1 -1
View File
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "Cachette"
rootProject.name = "Noxely"
include(":app")