Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adc6fc0486 | ||
|
|
8d96a9fdde | ||
|
|
b9da43366b | ||
|
|
b96354c40f | ||
|
|
4017c71ca7 |
No files matched your search
+3
-1
@@ -10,5 +10,7 @@ captures/
|
|||||||
keystore.properties
|
keystore.properties
|
||||||
*.jks
|
*.jks
|
||||||
.gitea-token
|
.gitea-token
|
||||||
Cachette-*.apk
|
# Motif large : un motif au nom du projet a laissé passer 95 Mo lors du renommage.
|
||||||
|
*.apk
|
||||||
update-token.properties
|
update-token.properties
|
||||||
|
app/src/main/assets/CHANGELOG.md
|
||||||
+124
-2
@@ -1,10 +1,132 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Toutes les évolutions notables de Cachette sont documentées ici.
|
Toutes les évolutions notables de Noxely sont documentées ici.
|
||||||
|
|
||||||
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
||||||
|
|
||||||
|
## [1.7.0] — 2026-09-13
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Guet sur l'ensemble des stations.** On nomme un morceau ou un artiste, sans désigner de
|
||||||
|
station : Noxely balaie les quarante stations les plus écoutées, quatre à la fois, et déclenche
|
||||||
|
l'enregistrement dès qu'une le diffuse. Un écran dédié sépare ce qui est attendu de ce qui a été
|
||||||
|
capté, avec le nombre de balayages et l'heure du dernier.
|
||||||
|
- **Veille en arrière-plan.** Deux mécanismes, parce qu'un seul ne suffit pas : une tâche
|
||||||
|
périodique tourne en permanence au quart d'heure — le minimum qu'autorise Android — et une
|
||||||
|
veille active, à enclencher, balaie toutes les trois minutes, écran éteint compris. Un morceau
|
||||||
|
durant trois à quatre minutes, la tâche périodique en manque forcément ; l'écran le dit au lieu
|
||||||
|
de le taire. La veille s'arrête d'elle-même quand plus rien n'est guetté.
|
||||||
|
- **Pochettes automatiques.** Les titres sans image en reçoivent une, cherchée sur leur nom via la
|
||||||
|
recherche publique d'iTunes, sans clé. Le nom est nettoyé au préalable : un enregistrement radio
|
||||||
|
traîne celui de sa station, qui brouillerait la requête.
|
||||||
|
- **Bouton d'actualisation** de l'écran Hors ligne : relance le scan de l'appareil et complète les
|
||||||
|
pochettes manquantes, par lots bornés pour ne pas marteler le service.
|
||||||
|
- L'écran de lecture affiche la provenance du morceau — nom de la playlist s'il en vient —, sa
|
||||||
|
source, sa taille et sa date.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- Un morceau guetté n'était pas enregistré : le champ de saisie était vidé avant que la coroutine
|
||||||
|
n'en lise la valeur. Elle est désormais capturée avant l'effacement.
|
||||||
|
|
||||||
|
## [1.6.0] — 2026-09-13
|
||||||
|
|
||||||
|
### Changé
|
||||||
|
|
||||||
|
- **L'application s'appelle désormais Noxely.** Le nom affiché, l'identifiant d'application
|
||||||
|
(`fr.tuxgyver.noxely`), le paquet Kotlin, le dépôt et le dossier d'enregistrements
|
||||||
|
(`Music/Noxely`) suivent tous.
|
||||||
|
|
||||||
|
⚠️ **Android considère le nouvel identifiant comme une application distincte.** Une
|
||||||
|
installation en 1.5.0 ne se met donc pas à jour toute seule : il faut la désinstaller, puis
|
||||||
|
installer Noxely. Les enregistrements déjà rangés dans `Music/Cachette` ne sont pas perdus —
|
||||||
|
ils sont dans le stockage partagé, restent lisibles par tout lecteur, et le scan continue de
|
||||||
|
les écarter pour ne pas les afficher en double.
|
||||||
|
|
||||||
|
- Nouvelle icône. Le visuel fourni est détouré de son fond, décliné aux cinq densités et posé
|
||||||
|
sur une couleur échantillonnée dans l'image : sans cela, le masque du système aurait découpé
|
||||||
|
une seconde fois un badge qui l'est déjà, en laissant du blanc sur les bords.
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- Page « À propos », accessible depuis l'écran Sources : ce que fait l'application, ce qu'elle
|
||||||
|
garantit, avec quoi elle est construite, ce qui a changé et qui en répond. La version est lue
|
||||||
|
du manifeste et le journal du CHANGELOG du dépôt, recopié à la compilation — l'un et l'autre
|
||||||
|
ne peuvent donc pas diverger de la réalité.
|
||||||
|
- La pastille de licence signale en rouge qu'aucune licence n'est déclarée dans le dépôt, plutôt
|
||||||
|
que d'en afficher une par défaut.
|
||||||
|
|
||||||
|
## [1.5.0] — 2026-09-13
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Titre en cours par station.** Aucun annuaire ne l'expose : le morceau diffusé est lu dans le
|
||||||
|
flux lui-même. Les douze premières stations affichées sont sondées quatre à la fois, et le
|
||||||
|
résultat conservé — au-delà, le nombre de connexions ne se justifierait plus. Les stations qui
|
||||||
|
n'annoncent rien gardent leur description.
|
||||||
|
- **Guet d'un titre.** On indique un morceau ou un artiste, et l'enregistrement se déclenche dès
|
||||||
|
que la station l'annonce, puis s'arrête à la fin du morceau. Le flux est lu sans être écrit
|
||||||
|
pendant l'attente. Une station sans métadonnées refuse la programmation plutôt que d'attendre
|
||||||
|
indéfiniment.
|
||||||
|
- **Catalogue Underground.** Restreint aux collections `netlabels` et `freemusicarchive`, où tout
|
||||||
|
est publié sous licence libre par les artistes : environ 94 000 morceaux indépendants, que la
|
||||||
|
recherche généraliste noyait.
|
||||||
|
- **Suppression individuelle d'un enregistrement** depuis l'écran Hors ligne : fichier, entrée
|
||||||
|
MediaStore et référence en bibliothèque sont retirés ensemble.
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- Les enregistrements sont désormais écrits dans `Music/Noxely` et déclarés dans MediaStore
|
||||||
|
(Android 10 et plus) : ils deviennent visibles des autres lecteurs et survivent à la
|
||||||
|
désinstallation. Le scan local ignore ce dossier, faute de quoi chaque enregistrement
|
||||||
|
apparaîtrait deux fois.
|
||||||
|
- Le nom de fichier porte le titre du morceau. En publiant le fichier, MediaStore le relit et
|
||||||
|
réécrit le champ `TITLE` depuis ce nom : l'y inscrire directement ne survivait pas.
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-09-13
|
||||||
|
|
||||||
|
### 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
|
## [1.2.0] — 2026-09-13
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
@@ -40,7 +162,7 @@ et le versionnage [SemVer](https://semver.org/lang/fr/).
|
|||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
||||||
- Mise à jour intégrée : Cachette surveille les releases du dépôt, télécharge l'APK en Wi-Fi
|
- Mise à jour intégrée : Noxely surveille les releases du dépôt, télécharge l'APK en Wi-Fi
|
||||||
et propose l'installation. Vérification quotidienne en tâche de fond, et à la demande depuis
|
et propose l'installation. Vérification quotidienne en tâche de fond, et à la demande depuis
|
||||||
l'écran Sources, qui affiche aussi la version installée.
|
l'écran Sources, qui affiche aussi la version installée.
|
||||||
- La taille approximative de chaque résultat Internet Archive est affichée avant sélection : un
|
- La taille approximative de chaque résultat Internet Archive est affichée avant sélection : un
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ DEBUG_APK := app/build/outputs/apk/debug/app-debug.apk
|
|||||||
# app-release-unsigned.apk) : on liste le répertoire au lieu de deviner.
|
# app-release-unsigned.apk) : on liste le répertoire au lieu de deviner.
|
||||||
RELEASE_DIR := app/build/outputs/apk/release
|
RELEASE_DIR := app/build/outputs/apk/release
|
||||||
SIGNED_APK := $(RELEASE_DIR)/app-release.apk
|
SIGNED_APK := $(RELEASE_DIR)/app-release.apk
|
||||||
OUTPUT_APK := Cachette-$(VERSION).apk
|
OUTPUT_APK := Noxely-$(VERSION).apk
|
||||||
|
|
||||||
# --- Publication -----------------------------------------------------------
|
# --- Publication -----------------------------------------------------------
|
||||||
# Déléguée au script de BorisSkill, qui sait déjà tout faire : résolution du
|
# Déléguée au script de BorisSkill, qui sait déjà tout faire : résolution du
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 🎧 Cachette
|
# 🎧 Noxely
|
||||||
|
|
||||||
> Mettez la musique et les vidéos **de côté** pendant que vous avez du réseau. Écoutez-les quand vous n'en avez plus.
|
> Mettez la musique et les vidéos **de côté** pendant que vous avez du réseau. Écoutez-les quand vous n'en avez plus.
|
||||||
|
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
[](https://developer.android.com/jetpack/compose)
|
[](https://developer.android.com/jetpack/compose)
|
||||||
[](https://developer.android.com/media/media3)
|
[](https://developer.android.com/media/media3)
|
||||||
[](VERSION)
|
[](VERSION)
|
||||||
[](#-comment-ça-marche)
|
[](#-comment-ça-marche)
|
||||||
|
|
||||||
Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
|
Noxely est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
|
||||||
forfaits épuisés. On choisit ce qu'on emporte, l'application le télécharge, et tout reste lisible
|
forfaits épuisés. On choisit ce qu'on emporte, l'application le télécharge, et tout reste lisible
|
||||||
sans la moindre connexion.
|
sans la moindre connexion.
|
||||||
|
|
||||||
@@ -41,7 +41,14 @@ sans la moindre connexion.
|
|||||||
| 🎛️ **Lecture en arrière-plan** | Notification système, casque, Bluetooth, écran verrouillé |
|
| 🎛️ **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 |
|
| ⏯️ **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 |
|
| 🌙 **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 |
|
||||||
|
| 📡 **Guet sur toutes les stations** | Nommez un morceau : Noxely balaie l'annuaire jusqu'à le trouver |
|
||||||
|
| 🖼️ **Pochettes automatiques** | Les titres sans image en reçoivent une, cherchée sur leur nom |
|
||||||
| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation |
|
| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +63,8 @@ sans la moindre connexion.
|
|||||||
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
|
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
|
||||||
| 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue |
|
| 📚 LibriVox | ✅ | Livres audio du domaine public, filtrables par langue |
|
||||||
| 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres |
|
| 🎼 Wikimedia Commons | ✅ | Enregistrements classiques et archives sonores libres |
|
||||||
|
| 📻 Radio | ✅ | Annuaire Radio Browser, direct et enregistrement (flux HLS exclus) |
|
||||||
|
| 🎧 Underground | ✅ | Netlabels et Free Music Archive : ~94 000 morceaux d'artistes indépendants |
|
||||||
|
|
||||||
### 🔎 Catalogues évalués puis écartés
|
### 🔎 Catalogues évalués puis écartés
|
||||||
|
|
||||||
@@ -65,7 +74,7 @@ sans la moindre connexion.
|
|||||||
| **ccMixter** | La recherche fonctionne, mais les fichiers ne sont servis qu'avec un en-tête `Referer` — que Media3 ne permet pas de poser par téléchargement. |
|
| **ccMixter** | La recherche fonctionne, mais les fichiers ne sont servis qu'avec un en-tête `Referer` — que Media3 ne permet pas de poser par téléchargement. |
|
||||||
| **Radio Browser** | Flux radio continus : rien à mettre en cache. |
|
| **Radio Browser** | Flux radio continus : rien à mettre en cache. |
|
||||||
|
|
||||||
### ⚖️ Ce que Cachette ne fait pas — et pourquoi
|
### ⚖️ Ce que Noxely ne fait pas — et pourquoi
|
||||||
|
|
||||||
| Service | Pourquoi |
|
| Service | Pourquoi |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -188,9 +197,33 @@ sortantes sont celles des URL que vous ajoutez et des catalogues que vous interr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> ⚠️ **Changement de nom en 1.6.0.** L'application s'appelait Cachette et son identifiant était
|
||||||
|
> `fr.tuxgyver.cachette`. Android traite le nouvel identifiant `fr.tuxgyver.noxely` comme une
|
||||||
|
> application distincte : une installation en 1.5.0 ne se met pas à jour toute seule, il faut la
|
||||||
|
> désinstaller puis installer Noxely. Les enregistrements déjà rangés dans `Music/Cachette`
|
||||||
|
> survivent — ils sont dans le stockage partagé — et restent lisibles par tout lecteur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📼 Où vont les enregistrements
|
||||||
|
|
||||||
|
À partir d'Android 10, dans `Music/Noxely/`, déclarés dans MediaStore : ils sont donc visibles
|
||||||
|
de tous les lecteurs de l'appareil et survivent à la désinstallation. En deçà, ils restent dans le
|
||||||
|
dossier privé de l'application, MediaStore y réclamant une permission de stockage dont Noxely n'a
|
||||||
|
pas besoin par ailleurs.
|
||||||
|
|
||||||
|
Le nom du fichier porte le titre du morceau — et pas seulement la colonne `TITLE` : en publiant le
|
||||||
|
fichier, MediaStore le relit et réécrit ce champ à partir du nom. Le scan local, lui, ignore
|
||||||
|
`Music/Noxely` pour ne pas afficher deux fois ce que la bibliothèque connaît déjà.
|
||||||
|
|
||||||
|
Chaque enregistrement se supprime individuellement depuis l'écran **Hors ligne** : fichier, entrée
|
||||||
|
MediaStore et référence en bibliothèque partent ensemble. « Tout libérer » ne les touche pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🆕 Mises à jour
|
## 🆕 Mises à jour
|
||||||
|
|
||||||
Cachette étant distribuée hors magasin, elle surveille elle-même les releases du dépôt.
|
Noxely étant distribuée hors magasin, elle surveille elle-même les releases du dépôt.
|
||||||
|
|
||||||
```
|
```
|
||||||
Release publiée ──► vérification quotidienne (WorkManager, Wi-Fi)
|
Release publiée ──► vérification quotidienne (WorkManager, Wi-Fi)
|
||||||
@@ -223,13 +256,14 @@ giteaUpdateToken=…
|
|||||||
|
|
||||||
```
|
```
|
||||||
app/src/main/java/fr/tuxgyver/cachette/
|
app/src/main/java/fr/tuxgyver/cachette/
|
||||||
├── CachetteApp.kt Application + conteneur de dépendances (assemblé à la main)
|
├── NoxelyApp.kt Application + conteneur de dépendances (assemblé à la main)
|
||||||
├── MainActivity.kt
|
├── MainActivity.kt
|
||||||
├── model/ MediaEntry, MediaKind, SourceKind
|
├── model/ MediaEntry, MediaKind, SourceKind
|
||||||
├── library/ MediaStore, persistance des imports, bibliothèque unifiée
|
├── library/ MediaStore, persistance des imports, bibliothèque unifiée
|
||||||
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
|
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
|
||||||
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
||||||
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
|
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
|
||||||
|
├── radio/ Annuaire des stations, service d'enregistrement du direct
|
||||||
├── update/ Vérification des releases, téléchargement, notification
|
├── update/ Vérification des releases, téléchargement, notification
|
||||||
└── ui/ Compose — thème, bibliothèque, hors ligne, playlists, sources,
|
└── ui/ Compose — thème, bibliothèque, hors ligne, playlists, sources,
|
||||||
parcours des catalogues, choix des pistes, lecteur
|
parcours des catalogues, choix des pistes, lecteur
|
||||||
|
|||||||
+10
-3
@@ -8,11 +8,11 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "fr.tuxgyver.cachette"
|
namespace = "fr.tuxgyver.noxely"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "fr.tuxgyver.cachette"
|
applicationId = "fr.tuxgyver.noxely"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ android {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
buildConfigField("String", "GITEA_UPDATE_TOKEN", "\"$updateToken\"")
|
buildConfigField("String", "GITEA_UPDATE_TOKEN", "\"$updateToken\"")
|
||||||
buildConfigField("String", "UPDATE_API", "\"https://git.tuxtech.fr/api/v1/repos/tuxgyver/Cachette\"")
|
buildConfigField("String", "UPDATE_API", "\"https://git.tuxtech.fr/api/v1/repos/tuxgyver/Noxely\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -77,6 +77,13 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Le journal affiché dans « À propos » est celui du dépôt, pas une copie qui dériverait. */
|
||||||
|
val copyChangelog = tasks.register<Copy>("copyChangelogToAssets") {
|
||||||
|
from(rootProject.file("CHANGELOG.md"))
|
||||||
|
into(layout.projectDirectory.dir("src/main/assets"))
|
||||||
|
}
|
||||||
|
tasks.named("preBuild") { dependsOn(copyChangelog) }
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform("androidx.compose:compose-bom:2025.10.00"))
|
implementation(platform("androidx.compose:compose-bom:2025.10.00"))
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CachetteApp"
|
android:name=".NoxelyApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -26,13 +26,13 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Cachette">
|
android:theme="@style/Theme.Noxely">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/Theme.Cachette"
|
android:theme="@style/Theme.Noxely"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".offline.CachetteDownloadService"
|
android:name=".offline.NoxelyDownloadService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync">
|
android:foregroundServiceType="dataSync">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -59,6 +59,16 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".radio.RecordingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".radio.WatchService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette
|
package fr.tuxgyver.noxely
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -9,8 +9,8 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import fr.tuxgyver.cachette.ui.CachetteRoot
|
import fr.tuxgyver.noxely.ui.NoxelyRoot
|
||||||
import fr.tuxgyver.cachette.ui.theme.CachetteTheme
|
import fr.tuxgyver.noxely.ui.theme.NoxelyTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -21,20 +21,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val container = (application as CachetteApp).container
|
val container = (application as NoxelyApp).container
|
||||||
container.player.connect()
|
container.player.connect()
|
||||||
askForNotificationsIfNeeded()
|
askForNotificationsIfNeeded()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
CachetteTheme {
|
NoxelyTheme {
|
||||||
CachetteRoot(container)
|
NoxelyRoot(container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
if (isFinishing) {
|
if (isFinishing) {
|
||||||
(application as CachetteApp).container.player.release()
|
(application as NoxelyApp).container.player.release()
|
||||||
}
|
}
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
+33
-19
@@ -1,24 +1,28 @@
|
|||||||
package fr.tuxgyver.cachette
|
package fr.tuxgyver.noxely
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import fr.tuxgyver.cachette.library.ImportedStore
|
import fr.tuxgyver.noxely.library.ArtworkFinder
|
||||||
import fr.tuxgyver.cachette.library.LibraryRepository
|
import fr.tuxgyver.noxely.library.ImportedStore
|
||||||
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
import fr.tuxgyver.noxely.library.LibraryRepository
|
||||||
import fr.tuxgyver.cachette.library.PlaylistStore
|
import fr.tuxgyver.noxely.library.LocalMediaScanner
|
||||||
import fr.tuxgyver.cachette.offline.DownloadTracker
|
import fr.tuxgyver.noxely.library.PlaylistStore
|
||||||
import fr.tuxgyver.cachette.offline.OfflineModule
|
import fr.tuxgyver.noxely.offline.DownloadTracker
|
||||||
import fr.tuxgyver.cachette.playback.PlayerController
|
import fr.tuxgyver.noxely.offline.OfflineModule
|
||||||
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
|
import fr.tuxgyver.noxely.playback.PlayerController
|
||||||
import fr.tuxgyver.cachette.sources.Catalog
|
import fr.tuxgyver.noxely.radio.RadioDirectory
|
||||||
import fr.tuxgyver.cachette.sources.CatalogId
|
import fr.tuxgyver.noxely.radio.WatchScanner
|
||||||
import fr.tuxgyver.cachette.sources.CatalogItem
|
import fr.tuxgyver.noxely.radio.WatchStore
|
||||||
import fr.tuxgyver.cachette.sources.WikimediaCatalog
|
import fr.tuxgyver.noxely.sources.ArchiveOrgCatalog
|
||||||
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
|
import fr.tuxgyver.noxely.sources.Catalog
|
||||||
import fr.tuxgyver.cachette.sources.RssImporter
|
import fr.tuxgyver.noxely.sources.CatalogId
|
||||||
import fr.tuxgyver.cachette.sources.UrlImporter
|
import fr.tuxgyver.noxely.sources.CatalogItem
|
||||||
import fr.tuxgyver.cachette.update.UpdateCheckWorker
|
import fr.tuxgyver.noxely.sources.WikimediaCatalog
|
||||||
import fr.tuxgyver.cachette.update.UpdateRepository
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -39,12 +43,21 @@ class AppContainer(context: Context) {
|
|||||||
val imported = ImportedStore(context, scope)
|
val imported = ImportedStore(context, scope)
|
||||||
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||||
val playlists = PlaylistStore(context, scope)
|
val playlists = PlaylistStore(context, scope)
|
||||||
|
val artwork = ArtworkFinder()
|
||||||
|
|
||||||
|
val radio = RadioDirectory()
|
||||||
|
val watches = WatchStore(context, scope)
|
||||||
|
val watchScanner = WatchScanner(radio, watches)
|
||||||
|
|
||||||
val urlImporter = UrlImporter()
|
val urlImporter = UrlImporter()
|
||||||
val rssImporter = RssImporter()
|
val rssImporter = RssImporter()
|
||||||
|
|
||||||
val catalogs: Map<CatalogId, Catalog> = listOf(
|
val catalogs: Map<CatalogId, Catalog> = listOf(
|
||||||
ArchiveOrgCatalog(),
|
ArchiveOrgCatalog(),
|
||||||
|
ArchiveOrgCatalog(
|
||||||
|
id = CatalogId.UNDERGROUND,
|
||||||
|
collections = listOf("netlabels", "freemusicarchive"),
|
||||||
|
),
|
||||||
LibriVoxCatalog(rssImporter),
|
LibriVoxCatalog(rssImporter),
|
||||||
WikimediaCatalog(),
|
WikimediaCatalog(),
|
||||||
).associateBy { it.id }
|
).associateBy { it.id }
|
||||||
@@ -60,7 +73,7 @@ class AppContainer(context: Context) {
|
|||||||
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
|
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
class CachetteApp : Application() {
|
class NoxelyApp : Application() {
|
||||||
|
|
||||||
lateinit var container: AppContainer
|
lateinit var container: AppContainer
|
||||||
private set
|
private set
|
||||||
@@ -69,5 +82,6 @@ class CachetteApp : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = AppContainer(this)
|
container = AppContainer(this)
|
||||||
UpdateCheckWorker.schedule(this)
|
UpdateCheckWorker.schedule(this)
|
||||||
|
WatchScanner.schedule(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package fr.tuxgyver.noxely.library
|
||||||
|
|
||||||
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
|
import fr.tuxgyver.noxely.sources.openConnection
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cherche une pochette pour un titre, via la recherche publique d'iTunes (sans clé).
|
||||||
|
*
|
||||||
|
* Un enregistrement radio ou un fichier importé n'a pas d'image : celle-ci est déduite du titre
|
||||||
|
* et de l'interprète. Rien n'est téléchargé ici — seule l'adresse est retenue, et l'affichage
|
||||||
|
* s'en charge, ce qui évite de stocker des images pour des médias qu'on ne regardera pas.
|
||||||
|
*/
|
||||||
|
class ArtworkFinder {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
suspend fun find(entry: MediaEntry): String? = withContext(Dispatchers.IO) {
|
||||||
|
val term = buildString {
|
||||||
|
append(entry.title.cleaned())
|
||||||
|
val artist = entry.subtitle.cleaned()
|
||||||
|
if (artist.isNotBlank() && !entry.title.contains(artist, ignoreCase = true)) {
|
||||||
|
append(' ').append(artist)
|
||||||
|
}
|
||||||
|
}.trim().take(120)
|
||||||
|
if (term.length < 3) return@withContext null
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val url = "https://itunes.apple.com/search" +
|
||||||
|
"?term=${URLEncoder.encode(term, "UTF-8")}&entity=song&limit=1"
|
||||||
|
val connection = openConnection(url, readTimeoutMs = 15_000)
|
||||||
|
val body = try {
|
||||||
|
if (connection.responseCode !in 200..299) return@runCatching null
|
||||||
|
connection.inputStream.bufferedReader().readText()
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
val first = (json.parseToJsonElement(body).jsonObject["results"] as? JsonArray)
|
||||||
|
?.firstOrNull() as? JsonObject ?: return@runCatching null
|
||||||
|
|
||||||
|
// La vignette est servie en 100 px ; l'adresse accepte une taille supérieure.
|
||||||
|
first["artworkUrl100"]?.jsonPrimitive?.content?.replace("100x100", "600x600")
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Les enregistrements radio traînent le nom de la station : il brouille la recherche. */
|
||||||
|
private fun String.cleaned(): String = this
|
||||||
|
.substringBefore(" - Classic Vinyl on ")
|
||||||
|
.replace(Regex("\\s+on\\s+\\S+\\.(com|fr|net|org)", RegexOption.IGNORE_CASE), "")
|
||||||
|
.replace(Regex("^\\d{1,3}\\s*[-.]\\s*"), "")
|
||||||
|
.replace(Regex("\\.(mp3|aac|ogg|m4a|flac)$", RegexOption.IGNORE_CASE), "")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
+11
-2
@@ -1,7 +1,7 @@
|
|||||||
package fr.tuxgyver.cachette.library
|
package fr.tuxgyver.noxely.library
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -45,6 +45,15 @@ class ImportedStore(context: Context, private val scope: CoroutineScope) {
|
|||||||
fresh.size
|
fresh.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renseigne la pochette trouvée pour une entrée, sans toucher au reste. */
|
||||||
|
suspend fun setArtwork(id: String, artworkUri: String) = mutex.withLock {
|
||||||
|
val merged = _entries.value.map { if (it.id == id) it.copy(artworkUri = artworkUri) else it }
|
||||||
|
if (merged != _entries.value) {
|
||||||
|
_entries.value = merged
|
||||||
|
write(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun remove(id: String) = mutex.withLock {
|
suspend fun remove(id: String) = mutex.withLock {
|
||||||
val merged = _entries.value.filterNot { it.id == id }
|
val merged = _entries.value.filterNot { it.id == id }
|
||||||
if (merged.size != _entries.value.size) {
|
if (merged.size != _entries.value.size) {
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package fr.tuxgyver.cachette.library
|
package fr.tuxgyver.noxely.library
|
||||||
|
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
+21
-5
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.library
|
package fr.tuxgyver.noxely.library
|
||||||
|
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -8,9 +8,9 @@ import androidx.core.net.toUri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class LocalMediaScanner(private val context: Context) {
|
|||||||
context.contentResolver.query(
|
context.contentResolver.query(
|
||||||
collection,
|
collection,
|
||||||
projection,
|
projection,
|
||||||
"${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0",
|
audioSelection(),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
)?.use { cursor ->
|
)?.use { cursor ->
|
||||||
@@ -123,6 +123,22 @@ class LocalMediaScanner(private val context: Context) {
|
|||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Musique et podcasts, hors enregistrements de Noxely : ceux-ci sont déjà décrits par la
|
||||||
|
* bibliothèque, et les relire ici les afficherait en double.
|
||||||
|
*/
|
||||||
|
private fun audioSelection(): String {
|
||||||
|
val base = "(${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0)"
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// L'ancien dossier reste exclu : les enregistrements d'avant le changement de nom
|
||||||
|
// sont toujours décrits par la bibliothèque et apparaîtraient sinon en double.
|
||||||
|
"$base AND ${MediaStore.Audio.Media.RELATIVE_PATH} NOT LIKE 'Music/Noxely%'" +
|
||||||
|
" AND ${MediaStore.Audio.Media.RELATIVE_PATH} NOT LIKE 'Music/Cachette%'"
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun albumArtUri(albumId: Long): Uri =
|
private fun albumArtUri(albumId: Long): Uri =
|
||||||
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
|
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
|
||||||
|
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
package fr.tuxgyver.cachette.library
|
package fr.tuxgyver.noxely.library
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import fr.tuxgyver.cachette.model.Playlist
|
import fr.tuxgyver.noxely.model.Playlist
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
+4
-3
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.model
|
package fr.tuxgyver.noxely.model
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ enum class SourceKind(val label: String) {
|
|||||||
LOCAL("Appareil"),
|
LOCAL("Appareil"),
|
||||||
URL("Lien direct"),
|
URL("Lien direct"),
|
||||||
PODCAST("Podcast"),
|
PODCAST("Podcast"),
|
||||||
|
RECORDING("Enregistrement"),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +32,6 @@ data class MediaEntry(
|
|||||||
/** Taille du fichier quand la source l'annonce, pour choisir avant de télécharger. */
|
/** Taille du fichier quand la source l'annonce, pour choisir avant de télécharger. */
|
||||||
val sizeBytes: Long? = null,
|
val sizeBytes: Long? = null,
|
||||||
) {
|
) {
|
||||||
/** Un média local est déjà disponible hors connexion : il n'a rien à télécharger. */
|
/** Local ou enregistré : le fichier est déjà sur l'appareil, il n'y a rien à télécharger. */
|
||||||
val isRemote: Boolean get() = source != SourceKind.LOCAL
|
val isRemote: Boolean get() = source != SourceKind.LOCAL && source != SourceKind.RECORDING
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.model
|
package fr.tuxgyver.noxely.model
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
+59
-10
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.offline
|
package fr.tuxgyver.noxely.offline
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -8,7 +8,7 @@ import androidx.media3.exoplayer.offline.Download
|
|||||||
import androidx.media3.exoplayer.offline.DownloadManager
|
import androidx.media3.exoplayer.offline.DownloadManager
|
||||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||||
import androidx.media3.exoplayer.offline.DownloadService
|
import androidx.media3.exoplayer.offline.DownloadService
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -28,13 +28,15 @@ data class OfflineState(
|
|||||||
val state: Int,
|
val state: Int,
|
||||||
val percent: Float,
|
val percent: Float,
|
||||||
val bytesDownloaded: Long,
|
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 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
|
val isPending: Boolean
|
||||||
get() = state == Download.STATE_QUEUED || state == Download.STATE_RESTARTING
|
get() = state == Download.STATE_QUEUED || state == Download.STATE_RESTARTING
|
||||||
val isRunning: Boolean get() = state == Download.STATE_DOWNLOADING
|
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(
|
class DownloadTracker(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val indexed = MutableStateFlow<Map<String, OfflineState>>(emptyMap())
|
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 {
|
private val ticker = flow {
|
||||||
while (true) {
|
while (true) {
|
||||||
emit(Unit)
|
emit(Unit)
|
||||||
@@ -60,20 +65,58 @@ class DownloadTracker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val states: StateFlow<Map<String, OfflineState>> =
|
val states: StateFlow<Map<String, OfflineState>> =
|
||||||
indexed.combine(ticker) { snapshot, _ ->
|
combine(indexed, ticker, retrying) { snapshot, _, pending ->
|
||||||
snapshot + downloadManager.currentDownloads.associate { it.request.id to it.toOfflineState() }
|
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())
|
}.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 {
|
private val listener = object : DownloadManager.Listener {
|
||||||
override fun onDownloadChanged(manager: DownloadManager, download: Download, finalException: Exception?) {
|
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() }
|
scope.launch { reload() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||||
|
attempts.remove(download.request.id)
|
||||||
scope.launch { reload() }
|
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 {
|
init {
|
||||||
downloadManager.addListener(listener)
|
downloadManager.addListener(listener)
|
||||||
scope.launch { reload() }
|
scope.launch { reload() }
|
||||||
@@ -81,18 +124,20 @@ class DownloadTracker(
|
|||||||
|
|
||||||
fun download(entry: MediaEntry) {
|
fun download(entry: MediaEntry) {
|
||||||
if (!entry.isRemote) return
|
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())
|
val request = DownloadRequest.Builder(entry.id, entry.uri.toUri())
|
||||||
.setData(entry.title.toByteArray())
|
.setData(entry.title.toByteArray())
|
||||||
.build()
|
.build()
|
||||||
DownloadService.sendAddDownload(context, CachetteDownloadService::class.java, request, false)
|
DownloadService.sendAddDownload(context, NoxelyDownloadService::class.java, request, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(id: String) {
|
fun remove(id: String) {
|
||||||
DownloadService.sendRemoveDownload(context, CachetteDownloadService::class.java, id, false)
|
DownloadService.sendRemoveDownload(context, NoxelyDownloadService::class.java, id, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeAll() {
|
fun removeAll() {
|
||||||
DownloadService.sendRemoveAllDownloads(context, CachetteDownloadService::class.java, false)
|
DownloadService.sendRemoveAllDownloads(context, NoxelyDownloadService::class.java, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reload() = withContext(Dispatchers.IO) {
|
private suspend fun reload() = withContext(Dispatchers.IO) {
|
||||||
@@ -108,6 +153,10 @@ class DownloadTracker(
|
|||||||
indexed.value = snapshot
|
indexed.value = snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val RETRY_DELAYS_MS = longArrayOf(5_000, 15_000, 45_000)
|
||||||
|
}
|
||||||
|
|
||||||
private fun Download.toOfflineState() = OfflineState(
|
private fun Download.toOfflineState() = OfflineState(
|
||||||
id = request.id,
|
id = request.id,
|
||||||
state = state,
|
state = state,
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.offline
|
package fr.tuxgyver.noxely.offline
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -9,15 +9,15 @@ import androidx.media3.exoplayer.offline.DownloadService
|
|||||||
import androidx.media3.exoplayer.scheduler.Scheduler
|
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||||
import androidx.media3.exoplayer.workmanager.WorkManagerScheduler
|
import androidx.media3.exoplayer.workmanager.WorkManagerScheduler
|
||||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||||
import fr.tuxgyver.cachette.CachetteApp
|
import fr.tuxgyver.noxely.NoxelyApp
|
||||||
import fr.tuxgyver.cachette.R
|
import fr.tuxgyver.noxely.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service de mise en cache. Il tourne en premier plan pendant les téléchargements et délègue la
|
* Service de mise en cache. Il tourne en premier plan pendant les téléchargements et délègue la
|
||||||
* reprise après coupure réseau ou redémarrage à WorkManager.
|
* reprise après coupure réseau ou redémarrage à WorkManager.
|
||||||
*/
|
*/
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class CachetteDownloadService : DownloadService(
|
class NoxelyDownloadService : DownloadService(
|
||||||
NOTIFICATION_ID,
|
NOTIFICATION_ID,
|
||||||
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
@@ -29,7 +29,7 @@ class CachetteDownloadService : DownloadService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getDownloadManager(): DownloadManager =
|
override fun getDownloadManager(): DownloadManager =
|
||||||
(application as CachetteApp).container.offline.downloadManager
|
(application as NoxelyApp).container.offline.downloadManager
|
||||||
|
|
||||||
override fun getScheduler(): Scheduler = WorkManagerScheduler(this, WORK_NAME)
|
override fun getScheduler(): Scheduler = WorkManagerScheduler(this, WORK_NAME)
|
||||||
|
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.offline
|
package fr.tuxgyver.noxely.offline
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -31,7 +31,7 @@ class OfflineModule(private val context: Context) {
|
|||||||
|
|
||||||
private val httpDataSourceFactory: DataSource.Factory by lazy {
|
private val httpDataSourceFactory: DataSource.Factory by lazy {
|
||||||
DefaultHttpDataSource.Factory()
|
DefaultHttpDataSource.Factory()
|
||||||
.setUserAgent("Cachette/1.0 (Android)")
|
.setUserAgent("Noxely/1.0 (Android)")
|
||||||
.setAllowCrossProtocolRedirects(true)
|
.setAllowCrossProtocolRedirects(true)
|
||||||
.setConnectTimeoutMs(20_000)
|
.setConnectTimeoutMs(20_000)
|
||||||
.setReadTimeoutMs(30_000)
|
.setReadTimeoutMs(30_000)
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
package fr.tuxgyver.cachette.playback
|
package fr.tuxgyver.noxely.playback
|
||||||
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Les `MediaItem` transmis par un contrôleur perdent leur URI en chemin : c'est le service qui la
|
* Les `MediaItem` transmis par un contrôleur perdent leur URI en chemin : c'est le service qui la
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.playback
|
package fr.tuxgyver.noxely.playback
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -13,8 +13,8 @@ import androidx.media3.session.MediaSession
|
|||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
import com.google.common.util.concurrent.Futures
|
import com.google.common.util.concurrent.Futures
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import fr.tuxgyver.cachette.CachetteApp
|
import fr.tuxgyver.noxely.NoxelyApp
|
||||||
import fr.tuxgyver.cachette.MainActivity
|
import fr.tuxgyver.noxely.MainActivity
|
||||||
|
|
||||||
/** Lecture audio et vidéo en arrière-plan, avec notification et commandes système. */
|
/** Lecture audio et vidéo en arrière-plan, avec notification et commandes système. */
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@@ -24,7 +24,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
val container = (application as CachetteApp).container
|
val container = (application as NoxelyApp).container
|
||||||
|
|
||||||
val player = ExoPlayer.Builder(this)
|
val player = ExoPlayer.Builder(this)
|
||||||
.setMediaSourceFactory(DefaultMediaSourceFactory(container.offline.playbackDataSourceFactory()))
|
.setMediaSourceFactory(DefaultMediaSourceFactory(container.offline.playbackDataSourceFactory()))
|
||||||
@@ -76,7 +76,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
controller: MediaSession.ControllerInfo,
|
controller: MediaSession.ControllerInfo,
|
||||||
mediaItems: MutableList<MediaItem>,
|
mediaItems: MutableList<MediaItem>,
|
||||||
): ListenableFuture<MutableList<MediaItem>> {
|
): ListenableFuture<MutableList<MediaItem>> {
|
||||||
val library = (application as CachetteApp).container.library
|
val library = (application as NoxelyApp).container.library
|
||||||
val resolved = mediaItems.mapTo(mutableListOf()) { item ->
|
val resolved = mediaItems.mapTo(mutableListOf()) { item ->
|
||||||
library.entry(item.mediaId)?.toMediaItem() ?: item
|
library.entry(item.mediaId)?.toMediaItem() ?: item
|
||||||
}
|
}
|
||||||
+10
-4
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.playback
|
package fr.tuxgyver.noxely.playback
|
||||||
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -6,8 +6,8 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -28,6 +28,8 @@ data class PlayerUiState(
|
|||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
val hasNext: Boolean = false,
|
val hasNext: Boolean = false,
|
||||||
val hasPrevious: Boolean = false,
|
val hasPrevious: Boolean = false,
|
||||||
|
/** D'où vient la file en cours : nom de playlist, d'album, de station. */
|
||||||
|
val queueLabel: String? = null,
|
||||||
) {
|
) {
|
||||||
val isIdle: Boolean get() = mediaId == null
|
val isIdle: Boolean get() = mediaId == null
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ class PlayerController(
|
|||||||
private val resolve: (String) -> MediaEntry?,
|
private val resolve: (String) -> MediaEntry?,
|
||||||
) {
|
) {
|
||||||
private var controller: MediaController? = null
|
private var controller: MediaController? = null
|
||||||
|
private var queueLabel: String? = null
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PlayerUiState())
|
private val _state = MutableStateFlow(PlayerUiState())
|
||||||
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
|
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
|
||||||
@@ -81,9 +84,10 @@ class PlayerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Lance une file de lecture à partir de l'entrée choisie. */
|
/** Lance une file de lecture à partir de l'entrée choisie. */
|
||||||
fun play(queue: List<MediaEntry>, startIndex: Int) {
|
fun play(queue: List<MediaEntry>, startIndex: Int, label: String? = null) {
|
||||||
val controller = controller ?: return
|
val controller = controller ?: return
|
||||||
if (queue.isEmpty()) return
|
if (queue.isEmpty()) return
|
||||||
|
queueLabel = label
|
||||||
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
|
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
|
||||||
controller.prepare()
|
controller.prepare()
|
||||||
controller.play()
|
controller.play()
|
||||||
@@ -108,6 +112,7 @@ class PlayerController(
|
|||||||
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
|
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
queueLabel = null
|
||||||
controller?.stop()
|
controller?.stop()
|
||||||
controller?.clearMediaItems()
|
controller?.clearMediaItems()
|
||||||
syncState()
|
syncState()
|
||||||
@@ -132,6 +137,7 @@ class PlayerController(
|
|||||||
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
|
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
|
||||||
hasNext = controller.hasNextMediaItem(),
|
hasNext = controller.hasNextMediaItem(),
|
||||||
hasPrevious = controller.hasPreviousMediaItem(),
|
hasPrevious = controller.hasPreviousMediaItem(),
|
||||||
|
queueLabel = queueLabel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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,101 @@
|
|||||||
|
package fr.tuxgyver.noxely.radio
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import fr.tuxgyver.noxely.NoxelyApp
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat d'un balayage, tel que l'interface le rapporte.
|
||||||
|
*/
|
||||||
|
data class ScanOutcome(
|
||||||
|
val stationsProbed: Int,
|
||||||
|
val titlesRead: Int,
|
||||||
|
val matched: String?,
|
||||||
|
val matchedOn: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passe les stations en revue à la recherche des morceaux guettés.
|
||||||
|
*
|
||||||
|
* Chaque station doit être ouverte pour livrer son titre : le balayage est donc borné aux
|
||||||
|
* stations les plus écoutées, par paquets, et non lancé en continu.
|
||||||
|
*
|
||||||
|
* ⚠️ Un morceau dure trois à quatre minutes, et WorkManager n'autorise pas de tâche périodique
|
||||||
|
* plus fréquente qu'un quart d'heure. Le balayage automatique ne peut donc pas tout attraper :
|
||||||
|
* il repère ce qui passe au moment où il regarde. Le balayage manuel existe pour cela.
|
||||||
|
*/
|
||||||
|
class WatchScanner(
|
||||||
|
private val directory: RadioDirectory,
|
||||||
|
private val store: WatchStore,
|
||||||
|
) {
|
||||||
|
suspend fun scan(context: Context, stationLimit: Int = STATION_LIMIT): ScanOutcome {
|
||||||
|
val pending = store.pending()
|
||||||
|
if (pending.isEmpty()) return ScanOutcome(0, 0, null, null)
|
||||||
|
|
||||||
|
val stations = directory.popular().getOrNull()?.take(stationLimit).orEmpty()
|
||||||
|
var titles = 0
|
||||||
|
|
||||||
|
stations.chunked(CONCURRENCY).forEach { group ->
|
||||||
|
val heard = coroutineScope {
|
||||||
|
group.map { station -> async { station to directory.nowPlaying(station.streamUrl) } }
|
||||||
|
.awaitAll()
|
||||||
|
}
|
||||||
|
for ((station, title) in heard) {
|
||||||
|
if (title == null) continue
|
||||||
|
titles++
|
||||||
|
val hit = pending.firstOrNull { title.contains(it.query, ignoreCase = true) }
|
||||||
|
?: continue
|
||||||
|
|
||||||
|
// Un seul enregistrement à la fois : inutile d'en réclamer un second.
|
||||||
|
if (!Recorder.status.value.isRecording) {
|
||||||
|
RecordingService.start(context, station, whenTitleContains = hit.query)
|
||||||
|
}
|
||||||
|
store.markFound(hit.id, station.name, title)
|
||||||
|
store.markScanned(pending.map { it.id })
|
||||||
|
return ScanOutcome(stations.size, titles, title, station.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.markScanned(pending.map { it.id })
|
||||||
|
return ScanOutcome(stations.size, titles, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STATION_LIMIT = 40
|
||||||
|
private const val CONCURRENCY = 4
|
||||||
|
private const val WORK_NAME = "noxely-watch-scan"
|
||||||
|
|
||||||
|
/** Un quart d'heure est le minimum autorisé pour une tâche périodique. */
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val request = PeriodicWorkRequestBuilder<WatchWorker>(15, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WatchWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val container = (applicationContext as NoxelyApp).container
|
||||||
|
if (container.watches.pending().isEmpty()) return Result.success()
|
||||||
|
runCatching { container.watchScanner.scan(applicationContext) }
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package fr.tuxgyver.noxely.radio
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import fr.tuxgyver.noxely.MainActivity
|
||||||
|
import fr.tuxgyver.noxely.NoxelyApp
|
||||||
|
import fr.tuxgyver.noxely.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Veille active : balaie les stations en continu tant qu'un morceau est guetté.
|
||||||
|
*
|
||||||
|
* La tâche périodique du système ne descend pas sous le quart d'heure, quand un morceau dure
|
||||||
|
* trois à quatre minutes : elle ne peut donc qu'en manquer. Cette veille tourne en service de
|
||||||
|
* premier plan, visible et interruptible, et s'arrête d'elle-même quand il n'y a plus rien à
|
||||||
|
* guetter — il n'y a pas de raison de consommer pour rien.
|
||||||
|
*/
|
||||||
|
class WatchService : Service() {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
finish()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
if (job?.isActive == true) return START_STICKY
|
||||||
|
|
||||||
|
val container = (application as NoxelyApp).container
|
||||||
|
running.value = true
|
||||||
|
startForegroundCompat(notification(container.watches.pending().size, null))
|
||||||
|
|
||||||
|
job = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val pending = container.watches.pending()
|
||||||
|
if (pending.isEmpty()) break
|
||||||
|
|
||||||
|
val outcome = runCatching { container.watchScanner.scan(applicationContext) }.getOrNull()
|
||||||
|
notify(container.watches.pending().size, outcome?.matched)
|
||||||
|
|
||||||
|
if (container.watches.pending().isEmpty()) break
|
||||||
|
delay(SWEEP_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
running.value = false
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
running.value = false
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundCompat(notification: Notification) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notify(pending: Int, lastMatch: String?) {
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.notify(NOTIFICATION_ID, notification(pending, lastMatch))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(pending: Int, lastMatch: String?): Notification {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "Veille radio", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
val stop = PendingIntent.getService(
|
||||||
|
this, 1,
|
||||||
|
Intent(this, WatchService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_offline)
|
||||||
|
.setContentTitle("Veille radio · $pending morceau(x) guetté(s)")
|
||||||
|
.setContentText(lastMatch?.let { "Dernier capté : $it" } ?: "Balayage toutes les 3 minutes")
|
||||||
|
.setContentIntent(open)
|
||||||
|
.addAction(0, "Arrêter", stop)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACTION_STOP = "fr.tuxgyver.noxely.WATCH_STOP"
|
||||||
|
private const val NOTIFICATION_ID = 4501
|
||||||
|
private const val CHANNEL_ID = "noxely_watch"
|
||||||
|
|
||||||
|
/** Trois minutes : assez serré pour croiser un morceau, assez large pour la batterie. */
|
||||||
|
private const val SWEEP_INTERVAL_MS = 180_000L
|
||||||
|
|
||||||
|
private val running = MutableStateFlow(false)
|
||||||
|
val isRunning: StateFlow<Boolean> = running.asStateFlow()
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
context.startForegroundService(Intent(context, WatchService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(Intent(context, WatchService::class.java).setAction(ACTION_STOP))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package fr.tuxgyver.noxely.radio
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Un morceau guetté sur l'ensemble des stations. */
|
||||||
|
@Serializable
|
||||||
|
data class Watch(
|
||||||
|
val id: String,
|
||||||
|
val query: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
/** Renseignés une fois le morceau capté. */
|
||||||
|
val foundAt: Long? = null,
|
||||||
|
val foundOn: String? = null,
|
||||||
|
val foundTitle: String? = null,
|
||||||
|
val lastScanAt: Long? = null,
|
||||||
|
val scans: Int = 0,
|
||||||
|
) {
|
||||||
|
val isFound: Boolean get() = foundAt != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persistance des guets, sur le même principe que les playlists. */
|
||||||
|
class WatchStore(context: Context, scope: CoroutineScope) {
|
||||||
|
|
||||||
|
private val file = File(context.filesDir, "watches.json")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val _watches = MutableStateFlow<List<Watch>>(emptyList())
|
||||||
|
val watches: StateFlow<List<Watch>> = _watches.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { _watches.value = read() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun add(query: String) = mutate { current ->
|
||||||
|
val cleaned = query.trim()
|
||||||
|
if (cleaned.isBlank() || current.any { it.query.equals(cleaned, true) && !it.isFound }) current
|
||||||
|
else current + Watch(UUID.randomUUID().toString(), cleaned, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } }
|
||||||
|
|
||||||
|
suspend fun markFound(id: String, station: String, title: String) = mutate { current ->
|
||||||
|
current.map {
|
||||||
|
if (it.id == id) it.copy(foundAt = System.currentTimeMillis(), foundOn = station, foundTitle = title)
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markScanned(ids: Collection<String>) = mutate { current ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
current.map { if (it.id in ids) it.copy(lastScanAt = now, scans = it.scans + 1) else it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Les guets encore en attente, seuls concernés par un balayage. */
|
||||||
|
fun pending(): List<Watch> = _watches.value.filterNot { it.isFound }
|
||||||
|
|
||||||
|
private suspend fun mutate(transform: (List<Watch>) -> List<Watch>) = mutex.withLock {
|
||||||
|
val updated = transform(_watches.value)
|
||||||
|
if (updated != _watches.value) {
|
||||||
|
_watches.value = updated
|
||||||
|
write(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun read(): List<Watch> = withContext(Dispatchers.IO) {
|
||||||
|
if (!file.exists()) return@withContext emptyList()
|
||||||
|
runCatching { json.decodeFromString<List<Watch>>(file.readText()) }.getOrElse { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(watches: List<Watch>) = withContext(Dispatchers.IO) {
|
||||||
|
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||||
|
tmp.writeText(json.encodeToString(watches))
|
||||||
|
tmp.renameTo(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-8
@@ -1,8 +1,8 @@
|
|||||||
package fr.tuxgyver.cachette.sources
|
package fr.tuxgyver.noxely.sources
|
||||||
|
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -19,9 +19,16 @@ import java.net.URLEncoder
|
|||||||
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
|
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
|
||||||
* trente éléments dont vingt-neuf seront ignorés.
|
* trente éléments dont vingt-neuf seront ignorés.
|
||||||
*/
|
*/
|
||||||
class ArchiveOrgCatalog : Catalog {
|
class ArchiveOrgCatalog(
|
||||||
|
override val id: CatalogId = CatalogId.ARCHIVE,
|
||||||
override val id = CatalogId.ARCHIVE
|
/**
|
||||||
|
* Collections auxquelles se restreindre. Vide : tout archive.org.
|
||||||
|
* `netlabels` et `freemusicarchive` ne contiennent que des publications sous licence libre,
|
||||||
|
* ce qui donne un fonds indépendant sans les dépôts de titres commerciaux qu'on croise
|
||||||
|
* ailleurs sur le site.
|
||||||
|
*/
|
||||||
|
private val collections: List<String> = emptyList(),
|
||||||
|
) : Catalog {
|
||||||
|
|
||||||
override val languages = COMMON_LANGUAGES
|
override val languages = COMMON_LANGUAGES
|
||||||
|
|
||||||
@@ -33,8 +40,13 @@ class ArchiveOrgCatalog : Catalog {
|
|||||||
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
|
// Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte
|
||||||
// dès la recherche plutôt que de les proposer puis d'échouer.
|
// dès la recherche plutôt que de les proposer puis d'échouer.
|
||||||
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
|
val languageClause = language?.let { " AND language:($it)" }.orEmpty()
|
||||||
|
val collectionClause =
|
||||||
|
collections.takeIf { it.isNotEmpty() }
|
||||||
|
?.joinToString(" OR ")
|
||||||
|
?.let { " AND collection:($it)" }
|
||||||
|
.orEmpty()
|
||||||
val q = URLEncoder.encode(
|
val q = URLEncoder.encode(
|
||||||
"($query) AND mediatype:(audio OR movies)$languageClause" +
|
"($query) AND mediatype:(audio OR movies)$languageClause$collectionClause" +
|
||||||
" AND NOT access-restricted-item:true",
|
" AND NOT access-restricted-item:true",
|
||||||
"UTF-8",
|
"UTF-8",
|
||||||
)
|
)
|
||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
package fr.tuxgyver.cachette.sources
|
package fr.tuxgyver.noxely.sources
|
||||||
|
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
|
|
||||||
/** Un résultat de recherche : un album, un livre ou un morceau, pas encore un fichier. */
|
/** Un résultat de recherche : un album, un livre ou un morceau, pas encore un fichier. */
|
||||||
data class CatalogItem(
|
data class CatalogItem(
|
||||||
@@ -52,6 +52,12 @@ enum class CatalogId(
|
|||||||
license = "Domaine public",
|
license = "Domaine public",
|
||||||
suggestion = "Jules Verne",
|
suggestion = "Jules Verne",
|
||||||
),
|
),
|
||||||
|
UNDERGROUND(
|
||||||
|
title = "Underground",
|
||||||
|
description = "Netlabels et Free Music Archive : des dizaines de milliers de morceaux d'artistes indépendants, publiés sous licence libre.",
|
||||||
|
license = "Creative Commons / netlabels",
|
||||||
|
suggestion = "ambient",
|
||||||
|
),
|
||||||
WIKIMEDIA(
|
WIKIMEDIA(
|
||||||
title = "Wikimedia Commons",
|
title = "Wikimedia Commons",
|
||||||
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
|
description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.",
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.sources
|
package fr.tuxgyver.noxely.sources
|
||||||
|
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
@@ -19,5 +19,5 @@ internal fun openConnection(
|
|||||||
instanceFollowRedirects = true
|
instanceFollowRedirects = true
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = readTimeoutMs
|
readTimeout = readTimeoutMs
|
||||||
setRequestProperty("User-Agent", "Cachette/1.1 (Android)")
|
setRequestProperty("User-Agent", "Noxely/1.1 (Android)")
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package fr.tuxgyver.cachette.sources
|
package fr.tuxgyver.noxely.sources
|
||||||
|
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
package fr.tuxgyver.cachette.sources
|
package fr.tuxgyver.noxely.sources
|
||||||
|
|
||||||
import android.util.Xml
|
import android.util.Xml
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
package fr.tuxgyver.cachette.sources
|
package fr.tuxgyver.noxely.sources
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
package fr.tuxgyver.cachette.sources
|
package fr.tuxgyver.noxely.sources
|
||||||
|
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
+54
-18
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui
|
package fr.tuxgyver.noxely.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -35,21 +35,24 @@ import androidx.navigation.compose.NavHost
|
|||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import fr.tuxgyver.cachette.sources.CatalogId
|
import fr.tuxgyver.noxely.sources.CatalogId
|
||||||
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen
|
import fr.tuxgyver.noxely.ui.about.AboutScreen
|
||||||
import fr.tuxgyver.cachette.ui.browse.CatalogItemScreen
|
import fr.tuxgyver.noxely.ui.browse.CatalogBrowseScreen
|
||||||
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen
|
import fr.tuxgyver.noxely.ui.browse.CatalogItemScreen
|
||||||
import fr.tuxgyver.cachette.ui.library.LibraryScreen
|
import fr.tuxgyver.noxely.ui.browse.SourceBrowseScreen
|
||||||
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
|
import fr.tuxgyver.noxely.ui.library.LibraryScreen
|
||||||
import fr.tuxgyver.cachette.ui.player.MiniPlayer
|
import fr.tuxgyver.noxely.ui.offline.DownloadsScreen
|
||||||
import fr.tuxgyver.cachette.ui.playlists.AddToPlaylistSheet
|
import fr.tuxgyver.noxely.ui.player.MiniPlayer
|
||||||
import fr.tuxgyver.cachette.ui.playlists.PlaylistDetailScreen
|
import fr.tuxgyver.noxely.ui.radio.RadioScreen
|
||||||
import fr.tuxgyver.cachette.ui.playlists.PlaylistsScreen
|
import fr.tuxgyver.noxely.ui.radio.WatchListScreen
|
||||||
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
|
import fr.tuxgyver.noxely.ui.playlists.AddToPlaylistSheet
|
||||||
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
||||||
@@ -63,9 +66,12 @@ private const val BROWSE_ROUTE = "browse/{source}"
|
|||||||
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
||||||
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
|
private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item"
|
||||||
private const val PLAYLIST_ROUTE = "playlist/{playlist}"
|
private const val PLAYLIST_ROUTE = "playlist/{playlist}"
|
||||||
|
private const val RADIO_ROUTE = "radio"
|
||||||
|
private const val ABOUT_ROUTE = "about"
|
||||||
|
private const val WATCHES_ROUTE = "watches"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CachetteRoot(container: AppContainer) {
|
fun NoxelyRoot(container: AppContainer) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry?.destination?.route
|
val currentRoute = backStackEntry?.destination?.route
|
||||||
@@ -84,6 +90,10 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
container.player.play(queue, index)
|
container.player.play(queue, index)
|
||||||
expanded = true
|
expanded = true
|
||||||
}
|
}
|
||||||
|
val playFrom: (List<MediaEntry>, Int, String?) -> Unit = { queue, index, label ->
|
||||||
|
container.player.play(queue, index, label)
|
||||||
|
expanded = true
|
||||||
|
}
|
||||||
val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } }
|
val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
@@ -143,13 +153,37 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(ABOUT_ROUTE) {
|
||||||
|
AboutScreen(onBack = navController::popBackStack, contentPadding = padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(RADIO_ROUTE) {
|
||||||
|
RadioScreen(
|
||||||
|
container = container,
|
||||||
|
currentMediaId = playerState.mediaId,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
|
onMessage = notify,
|
||||||
|
onOpenWatches = { navController.navigate(WATCHES_ROUTE) },
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(WATCHES_ROUTE) {
|
||||||
|
WatchListScreen(
|
||||||
|
container = container,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
|
onMessage = notify,
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composable(PLAYLIST_ROUTE) { entry ->
|
composable(PLAYLIST_ROUTE) { entry ->
|
||||||
PlaylistDetailScreen(
|
PlaylistDetailScreen(
|
||||||
container = container,
|
container = container,
|
||||||
playlistId = entry.arguments?.getString("playlist").orEmpty(),
|
playlistId = entry.arguments?.getString("playlist").orEmpty(),
|
||||||
currentMediaId = playerState.mediaId,
|
currentMediaId = playerState.mediaId,
|
||||||
onBack = navController::popBackStack,
|
onBack = navController::popBackStack,
|
||||||
onPlay = play,
|
onPlay = playFrom,
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -161,6 +195,8 @@ fun CachetteRoot(container: AppContainer) {
|
|||||||
onMessage = notify,
|
onMessage = notify,
|
||||||
onBrowseSource = { navController.navigate("browse/${it.name}") },
|
onBrowseSource = { navController.navigate("browse/${it.name}") },
|
||||||
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
|
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
|
||||||
|
onBrowseRadio = { navController.navigate(RADIO_ROUTE) },
|
||||||
|
onOpenAbout = { navController.navigate(ABOUT_ROUTE) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package fr.tuxgyver.noxely.ui.about
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import fr.tuxgyver.noxely.R
|
||||||
|
|
||||||
|
private val TABS = listOf(
|
||||||
|
"⭐ Fonctions",
|
||||||
|
"🛡️ Garanties",
|
||||||
|
"🧱 Technique",
|
||||||
|
"🕓 Journal",
|
||||||
|
"👤 Auteur",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page « À propos » : ce que fait l'application, ce qu'elle garantit, avec quoi elle est faite,
|
||||||
|
* ce qui a changé et qui en répond. L'en-tête ne défile pas ; seul l'onglet courant défile.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AboutScreen(onBack: () -> Unit, contentPadding: PaddingValues) {
|
||||||
|
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("À propos") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Identity()
|
||||||
|
|
||||||
|
ScrollableTabRow(selectedTabIndex = tab, edgePadding = 16.dp) {
|
||||||
|
TABS.forEachIndexed { index, label ->
|
||||||
|
Tab(
|
||||||
|
selected = tab == index,
|
||||||
|
onClick = { tab = index },
|
||||||
|
text = { Text(label, style = MaterialTheme.typography.labelLarge) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (tab) {
|
||||||
|
0 -> FeaturesTab(listPadding)
|
||||||
|
1 -> GuaranteesTab(listPadding)
|
||||||
|
2 -> StackTab(listPadding)
|
||||||
|
3 -> ChangelogTab(listPadding)
|
||||||
|
else -> AuthorTab(listPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Identity() {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.mipmap.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp)),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text(AppInfo.NAME, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(
|
||||||
|
text = AppInfo.TAGLINE,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Pill("v${AppInfo.VERSION}", MaterialTheme.colorScheme.primary)
|
||||||
|
// Rouge tant qu'aucune licence n'est déclarée : une garantie qui manque doit se voir.
|
||||||
|
Pill(
|
||||||
|
text = AppInfo.LICENSE,
|
||||||
|
color = if (AppInfo.LICENSE_DEFINED) MaterialTheme.colorScheme.secondary
|
||||||
|
else MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Pill("Hors ligne", Color(0xFF2E7D32))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Pill(text: String, color: Color) {
|
||||||
|
Surface(color = color.copy(alpha = 0.14f), shape = CircleShape) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = color,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeaturesTab(padding: PaddingValues) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = padding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(AppInfo.FEATURES) { (title, description) ->
|
||||||
|
Column {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GuaranteesTab(padding: PaddingValues) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = padding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = AppInfo.GUARANTEES_INTRO,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
}
|
||||||
|
items(AppInfo.GUARANTEES) { guarantee ->
|
||||||
|
Row {
|
||||||
|
Text("•", color = MaterialTheme.colorScheme.secondary)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = guarantee,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StackTab(padding: PaddingValues) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = padding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(AppInfo.STACK) { (layer, tools) ->
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Text(
|
||||||
|
text = layer,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.width(116.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = tools,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChangelogTab(padding: PaddingValues) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lines = remember { readChangelog(context) }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = padding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Journal indisponible.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(lines) { line ->
|
||||||
|
when (line) {
|
||||||
|
is ChangelogLine.Version -> {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = line.text,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ChangelogLine.Section -> Text(
|
||||||
|
text = line.text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
|
||||||
|
is ChangelogLine.Bullet -> Row {
|
||||||
|
Text("•", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = emphasised(line.text),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le fichier n'emploie que du gras : inutile d'embarquer un analyseur Markdown complet. */
|
||||||
|
@Composable
|
||||||
|
private fun emphasised(text: String) = buildAnnotatedString {
|
||||||
|
var rest = text
|
||||||
|
while (true) {
|
||||||
|
val open = rest.indexOf("**")
|
||||||
|
if (open < 0) break
|
||||||
|
val close = rest.indexOf("**", open + 2)
|
||||||
|
if (close < 0) break
|
||||||
|
append(rest.substring(0, open))
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||||
|
append(rest.substring(open + 2, close))
|
||||||
|
}
|
||||||
|
rest = rest.substring(close + 2)
|
||||||
|
}
|
||||||
|
append(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthorTab(padding: PaddingValues) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = padding,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.primary, shape = CircleShape) {
|
||||||
|
Box(Modifier.size(52.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = AppInfo.AUTHOR.take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column {
|
||||||
|
Text(AppInfo.AUTHOR, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = AppInfo.AUTHOR_ROLE,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(18.dp))
|
||||||
|
Text("Dépôt", style = MaterialTheme.typography.labelMedium)
|
||||||
|
Text(
|
||||||
|
text = AppInfo.REPOSITORY,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(18.dp))
|
||||||
|
Text(
|
||||||
|
text = AppInfo.AUTHORSHIP,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package fr.tuxgyver.noxely.ui.about
|
||||||
|
|
||||||
|
import fr.tuxgyver.noxely.BuildConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenu de la page « À propos », séparé de son rendu.
|
||||||
|
*
|
||||||
|
* Tout ce qui est écrit ici se vérifie dans le dépôt : une fonction décrite existe, une garantie
|
||||||
|
* annoncée est tenue par du code. La version n'est pas recopiée mais lue du manifeste.
|
||||||
|
*/
|
||||||
|
object AppInfo {
|
||||||
|
|
||||||
|
const val NAME = "Noxely"
|
||||||
|
const val TAGLINE = "Mettez la musique de côté pendant que vous avez du réseau."
|
||||||
|
val VERSION: String get() = BuildConfig.VERSION_NAME
|
||||||
|
|
||||||
|
const val REPOSITORY = "https://git.tuxtech.fr/tuxgyver/Noxely"
|
||||||
|
const val AUTHOR = "Johnny"
|
||||||
|
const val AUTHOR_ROLE = "Conception, direction et supervision"
|
||||||
|
const val AUTHORSHIP =
|
||||||
|
"Application développée avec l'assistance de l'IA, sous la direction et la supervision de Johnny."
|
||||||
|
|
||||||
|
/** Aucune licence n'est déclarée dans le dépôt : la pastille le signale plutôt que d'en inventer une. */
|
||||||
|
const val LICENSE = "Licence à définir"
|
||||||
|
const val LICENSE_DEFINED = false
|
||||||
|
|
||||||
|
/** Ce que fait l'application, du point de vue de qui l'utilise. */
|
||||||
|
val FEATURES = listOf(
|
||||||
|
"Écouter sans réseau" to
|
||||||
|
"Ce que vous mettez de côté reste lisible dans le métro, en avion ou forfait épuisé. " +
|
||||||
|
"Audio et vidéo, avec les commandes au casque et sur l'écran verrouillé.",
|
||||||
|
"Choisir piste par piste" to
|
||||||
|
"Un album ou un livre affiche ses pistes avec leur durée et leur taille. Vous cochez " +
|
||||||
|
"ce que vous emportez, et le bouton annonce le poids exact avant de lancer.",
|
||||||
|
"Essayer avant de garder" to
|
||||||
|
"Un appui sur une piste la joue en ligne, sans rien mettre en cache. On décide après.",
|
||||||
|
"Puiser dans des catalogues libres" to
|
||||||
|
"Internet Archive, Underground (netlabels et Free Music Archive), LibriVox et " +
|
||||||
|
"Wikimedia Commons, sans compte ni clé, avec filtrage par langue.",
|
||||||
|
"Écouter et enregistrer la radio" to
|
||||||
|
"Les stations les plus écoutées, ce qu'elles diffusent à l'instant, et " +
|
||||||
|
"l'enregistrement d'un direct — qui prend le nom du morceau annoncé.",
|
||||||
|
"Guetter un morceau" to
|
||||||
|
"Vous nommez un titre : l'enregistrement se déclenche dès que la station l'annonce, " +
|
||||||
|
"et s'arrête à la fin du morceau.",
|
||||||
|
"Organiser en playlists" to
|
||||||
|
"Des listes nommées, alimentées d'un appui long, lues à la suite.",
|
||||||
|
"Rester à jour" to
|
||||||
|
"L'application surveille les publications du dépôt, télécharge la nouvelle version " +
|
||||||
|
"en Wi-Fi et propose de l'installer.",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Chaque promesse est tenue par du code, et dit où. */
|
||||||
|
val GUARANTEES_INTRO =
|
||||||
|
"Noxely est un outil personnel : rien n'y transite par un service tiers, et rien ne s'y " +
|
||||||
|
"télécharge sans un geste de votre part."
|
||||||
|
|
||||||
|
val GUARANTEES = listOf(
|
||||||
|
"Aucun compte, aucune inscription, aucune télémétrie : le projet n'embarque aucune " +
|
||||||
|
"bibliothèque d'analyse d'audience.",
|
||||||
|
"Les seules connexions sortantes sont celles des adresses que vous ajoutez et des " +
|
||||||
|
"catalogues que vous interrogez.",
|
||||||
|
"Le cache ne contient que ce que vous avez demandé à garder : rien n'y est écrit pendant " +
|
||||||
|
"une écoute en ligne, et rien n'en est effacé sans votre geste.",
|
||||||
|
"Le contenu mis en cache est exclu des sauvegardes dans le nuage et des transferts " +
|
||||||
|
"d'appareil à appareil.",
|
||||||
|
"Un téléchargement interrompu par une panne de serveur repart seul, trois fois, en " +
|
||||||
|
"espaçant les tentatives ; sans réseau, il attend son retour.",
|
||||||
|
"Les catalogues proposés ne présentent que des œuvres libres de droits ou du domaine " +
|
||||||
|
"public ; les éléments en accès restreint sont écartés avant même d'être affichés.",
|
||||||
|
"Le jeton qui sert à vérifier les mises à jour est en lecture seule et ne peut rien publier.",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Couche → outils. Six lignes suffisent. */
|
||||||
|
val STACK = listOf(
|
||||||
|
"Interface" to "Jetpack Compose, Material 3, thèmes clair et sombre",
|
||||||
|
"Lecture" to "Media3 / ExoPlayer, MediaSessionService, notification système",
|
||||||
|
"Hors connexion" to "Cache Media3 non évinçant, WorkManager pour la reprise",
|
||||||
|
"Bibliothèque" to "MediaStore pour l'appareil, fichiers JSON pour les imports",
|
||||||
|
"Réseau" to "HttpURLConnection, kotlinx.serialization, flux ICY pour la radio",
|
||||||
|
"Publication" to "APK signé, releases Gitea, mise à jour intégrée",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package fr.tuxgyver.noxely.ui.about
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/** Une ligne du journal, telle qu'elle sera rendue. */
|
||||||
|
sealed interface ChangelogLine {
|
||||||
|
data class Version(val text: String) : ChangelogLine
|
||||||
|
data class Section(val text: String) : ChangelogLine
|
||||||
|
data class Bullet(val text: String) : ChangelogLine
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le CHANGELOG livré dans les ressources.
|
||||||
|
*
|
||||||
|
* Le fichier est celui du dépôt, recopié dans les assets à la compilation : le journal affiché
|
||||||
|
* ne peut donc pas diverger de celui que la publication utilise pour ses notes de version.
|
||||||
|
* Seules les formes réellement employées par le fichier sont interprétées — inutile d'embarquer
|
||||||
|
* un analyseur Markdown complet pour trois motifs. Le préambule qui précède la première version
|
||||||
|
* est ignoré : il décrit la convention du fichier, pas l'histoire du logiciel.
|
||||||
|
*/
|
||||||
|
fun readChangelog(context: Context, limitVersions: Int = 6): List<ChangelogLine> {
|
||||||
|
val raw = runCatching {
|
||||||
|
context.assets.open("CHANGELOG.md").bufferedReader().readText()
|
||||||
|
}.getOrNull() ?: return emptyList()
|
||||||
|
|
||||||
|
val lines = mutableListOf<ChangelogLine>()
|
||||||
|
var versions = 0
|
||||||
|
|
||||||
|
for (line in raw.lineSequence()) {
|
||||||
|
val trimmed = line.trim()
|
||||||
|
when {
|
||||||
|
trimmed.startsWith("## ") -> {
|
||||||
|
versions++
|
||||||
|
if (versions > limitVersions) break
|
||||||
|
lines += ChangelogLine.Version(trimmed.removePrefix("## ").replace("[", "").replace("]", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed.startsWith("### ") ->
|
||||||
|
lines += ChangelogLine.Section(trimmed.removePrefix("### "))
|
||||||
|
|
||||||
|
trimmed.startsWith("- ") && versions > 0 ->
|
||||||
|
lines += ChangelogLine.Bullet(trimmed.removePrefix("- "))
|
||||||
|
|
||||||
|
// Une puce peut courir sur plusieurs lignes : on la recolle à la précédente.
|
||||||
|
trimmed.isNotEmpty() && versions > 0 && lines.lastOrNull() is ChangelogLine.Bullet -> {
|
||||||
|
val previous = lines.removeAt(lines.lastIndex) as ChangelogLine.Bullet
|
||||||
|
lines += ChangelogLine.Bullet("${previous.text} $trimmed")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.browse
|
package fr.tuxgyver.noxely.ui.browse
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
@@ -47,12 +47,12 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import fr.tuxgyver.cachette.sources.CatalogId
|
import fr.tuxgyver.noxely.sources.CatalogId
|
||||||
import fr.tuxgyver.cachette.sources.CatalogItem
|
import fr.tuxgyver.noxely.sources.CatalogItem
|
||||||
import fr.tuxgyver.cachette.ui.components.Artwork
|
import fr.tuxgyver.noxely.ui.components.Artwork
|
||||||
import fr.tuxgyver.cachette.ui.components.formatBytes
|
import fr.tuxgyver.noxely.ui.components.formatBytes
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.browse
|
package fr.tuxgyver.noxely.ui.browse
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -40,11 +40,11 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.sources.CatalogId
|
import fr.tuxgyver.noxely.sources.CatalogId
|
||||||
import fr.tuxgyver.cachette.ui.components.formatBytes
|
import fr.tuxgyver.noxely.ui.components.formatBytes
|
||||||
import fr.tuxgyver.cachette.ui.components.formatDuration
|
import fr.tuxgyver.noxely.ui.components.formatDuration
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.browse
|
package fr.tuxgyver.noxely.ui.browse
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.browse
|
package fr.tuxgyver.noxely.ui.browse
|
||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -35,10 +35,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import fr.tuxgyver.cachette.ui.components.MediaRow
|
import fr.tuxgyver.noxely.ui.components.MediaRow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parcours d'une source déjà présente dans la bibliothèque : on y choisit, un par un ou en lot,
|
* Parcours d'une source déjà présente dans la bibliothèque : on y choisit, un par un ou en lot,
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.components
|
package fr.tuxgyver.noxely.ui.components
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -28,7 +28,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.components
|
package fr.tuxgyver.noxely.ui.components
|
||||||
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.components
|
package fr.tuxgyver.noxely.ui.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
@@ -32,8 +32,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.offline.OfflineState
|
import fr.tuxgyver.noxely.offline.OfflineState
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
+8
-8
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.library
|
package fr.tuxgyver.noxely.ui.library
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -40,12 +40,12 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
import fr.tuxgyver.noxely.library.LocalMediaScanner
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import fr.tuxgyver.cachette.offline.OfflineState
|
import fr.tuxgyver.noxely.offline.OfflineState
|
||||||
import fr.tuxgyver.cachette.ui.components.MediaRow
|
import fr.tuxgyver.noxely.ui.components.MediaRow
|
||||||
|
|
||||||
private enum class LibraryFilter(val label: String) {
|
private enum class LibraryFilter(val label: String) {
|
||||||
ALL("Tout"),
|
ALL("Tout"),
|
||||||
@@ -201,7 +201,7 @@ private fun PermissionCard(onGrant: () -> Unit) {
|
|||||||
Text("Accéder à vos médias", style = MaterialTheme.typography.titleMedium)
|
Text("Accéder à vos médias", style = MaterialTheme.typography.titleMedium)
|
||||||
Spacer(Modifier.size(6.dp))
|
Spacer(Modifier.size(6.dp))
|
||||||
Text(
|
Text(
|
||||||
"Cachette lit la musique et les vidéos déjà présentes sur l'appareil. Rien n'est envoyé ailleurs.",
|
"Noxely lit la musique et les vidéos déjà présentes sur l'appareil. Rien n'est envoyé ailleurs.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(14.dp))
|
Spacer(Modifier.size(14.dp))
|
||||||
+113
-12
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.offline
|
package fr.tuxgyver.noxely.ui.offline
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -14,8 +14,12 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.CloudOff
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
|
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||||
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -27,17 +31,22 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.ui.components.MediaRow
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import fr.tuxgyver.cachette.ui.components.formatBytes
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -51,19 +60,32 @@ fun DownloadsScreen(
|
|||||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||||
var confirmClear by remember { mutableStateOf(false) }
|
var confirmClear by remember { mutableStateOf(false) }
|
||||||
|
var pendingRecordingDelete by remember { mutableStateOf<MediaEntry?>(null) }
|
||||||
|
var refreshing by remember { mutableStateOf(false) }
|
||||||
|
var refreshNote by remember { mutableStateOf<String?>(null) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
var cacheSize by remember { mutableLongStateOf(0L) }
|
var cacheSize by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
LaunchedEffect(offlineStates) {
|
// Un enregistrement radio n'a pas d'état de téléchargement : le fichier est déjà là.
|
||||||
cacheSize = withContext(Dispatchers.IO) { container.offline.cacheSizeBytes() }
|
// L'écran annoncerait sinon « disponibles sans réseau » en omettant ce qui l'est le plus.
|
||||||
}
|
|
||||||
|
|
||||||
val cached = remember(entries, offlineStates) {
|
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 }
|
.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 }
|
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(
|
LazyColumn(
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
@@ -79,7 +101,32 @@ fun DownloadsScreen(
|
|||||||
cacheSize = cacheSize,
|
cacheSize = cacheSize,
|
||||||
completed = completed,
|
completed = completed,
|
||||||
running = running,
|
running = running,
|
||||||
|
refreshing = refreshing,
|
||||||
|
note = refreshNote,
|
||||||
onClear = { confirmClear = true },
|
onClear = { confirmClear = true },
|
||||||
|
onRefresh = {
|
||||||
|
refreshing = true
|
||||||
|
refreshNote = null
|
||||||
|
scope.launch {
|
||||||
|
container.library.refreshLocal()
|
||||||
|
// Les pochettes manquantes sont cherchées une par une : un enregistrement
|
||||||
|
// radio ou un fichier importé n'en a aucune tant qu'on ne l'a pas cherchée.
|
||||||
|
val missing = cached.filter { it.artworkUri.isNullOrBlank() }
|
||||||
|
var found = 0
|
||||||
|
missing.take(MAX_ARTWORK_LOOKUPS).forEach { entry ->
|
||||||
|
container.artwork.find(entry)?.let { url ->
|
||||||
|
container.imported.setArtwork(entry.id, url)
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshing = false
|
||||||
|
refreshNote = when {
|
||||||
|
missing.isEmpty() -> "Tout est à jour"
|
||||||
|
found == 0 -> "Aucune pochette trouvée pour ${missing.size} titre(s)"
|
||||||
|
else -> "$found pochette(s) ajoutée(s)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(14.dp))
|
Spacer(Modifier.size(14.dp))
|
||||||
}
|
}
|
||||||
@@ -92,6 +139,8 @@ fun DownloadsScreen(
|
|||||||
items(cached, key = { it.id }) { entry ->
|
items(cached, key = { it.id }) { entry ->
|
||||||
val state = offlineStates[entry.id]
|
val state = offlineStates[entry.id]
|
||||||
Column {
|
Column {
|
||||||
|
val isRecording = entry.source == SourceKind.RECORDING
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
MediaRow(
|
MediaRow(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
offline = state,
|
offline = state,
|
||||||
@@ -100,7 +149,17 @@ fun DownloadsScreen(
|
|||||||
onDownload = { container.downloads.download(entry) },
|
onDownload = { container.downloads.download(entry) },
|
||||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
onLongClick = { onAddToPlaylist(entry) },
|
onLongClick = { onAddToPlaylist(entry) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
if (isRecording) {
|
||||||
|
IconButton(onClick = { pendingRecordingDelete = entry }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.DeleteOutline,
|
||||||
|
contentDescription = "Supprimer cet enregistrement",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (state?.isActive == true) {
|
if (state?.isActive == true) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (state.percent / 100f).coerceIn(0f, 1f) },
|
progress = { (state.percent / 100f).coerceIn(0f, 1f) },
|
||||||
@@ -113,12 +172,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) {
|
if (confirmClear) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmClear = false },
|
onDismissRequest = { confirmClear = false },
|
||||||
title = { Text("Vider la cachette ?") },
|
title = { Text("Vider la cachette ?") },
|
||||||
text = {
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
@@ -138,13 +217,17 @@ private fun StorageCard(
|
|||||||
cacheSize: Long,
|
cacheSize: Long,
|
||||||
completed: Int,
|
completed: Int,
|
||||||
running: Int,
|
running: Int,
|
||||||
|
refreshing: Boolean,
|
||||||
|
note: String?,
|
||||||
onClear: () -> Unit,
|
onClear: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(20.dp),
|
modifier = Modifier.padding(20.dp),
|
||||||
@@ -162,12 +245,30 @@ private fun StorageCard(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (refreshing) {
|
||||||
|
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = onRefresh) {
|
||||||
|
Icon(Icons.Rounded.Refresh, contentDescription = "Actualiser et chercher les pochettes")
|
||||||
|
}
|
||||||
|
}
|
||||||
if (completed > 0 || running > 0) {
|
if (completed > 0 || running > 0) {
|
||||||
TextButton(onClick = onClear) { Text("Tout libérer") }
|
TextButton(onClick = onClear) { Text("Tout libérer") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
note?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 14.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chaque recherche est une requête réseau : on borne le lot pour ne pas marteler le service. */
|
||||||
|
private const val MAX_ARTWORK_LOOKUPS = 25
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyOffline() {
|
private fun EmptyOffline() {
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.player
|
package fr.tuxgyver.noxely.ui.player
|
||||||
|
|
||||||
import androidx.compose.foundation.basicMarquee
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -26,8 +26,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import fr.tuxgyver.cachette.playback.PlayerUiState
|
import fr.tuxgyver.noxely.playback.PlayerUiState
|
||||||
import fr.tuxgyver.cachette.ui.components.Artwork
|
import fr.tuxgyver.noxely.ui.components.Artwork
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MiniPlayer(
|
fun MiniPlayer(
|
||||||
+48
-8
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.player
|
package fr.tuxgyver.noxely.ui.player
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -36,6 +36,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -54,12 +55,16 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
import fr.tuxgyver.cachette.offline.OfflineState
|
import fr.tuxgyver.noxely.offline.OfflineState
|
||||||
import fr.tuxgyver.cachette.playback.PlayerUiState
|
import fr.tuxgyver.noxely.playback.PlayerUiState
|
||||||
import fr.tuxgyver.cachette.ui.components.Artwork
|
import fr.tuxgyver.noxely.ui.components.Artwork
|
||||||
import fr.tuxgyver.cachette.ui.components.formatDuration
|
import fr.tuxgyver.noxely.ui.components.formatDuration
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import fr.tuxgyver.noxely.ui.components.formatBytes
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NowPlayingScreen(
|
fun NowPlayingScreen(
|
||||||
@@ -170,7 +175,12 @@ fun NowPlayingScreen(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.size(20.dp))
|
entry?.let { current ->
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
TrackDetails(entry = current, queueLabel = state.queueLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
|
||||||
Slider(
|
Slider(
|
||||||
value = scrubbing ?: if (state.durationMs > 0) {
|
value = scrubbing ?: if (state.durationMs > 0) {
|
||||||
@@ -264,6 +274,36 @@ private fun SkipButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ce qu'on sait du morceau en cours : d'où il est lu, ce qu'il pèse, quand il est arrivé.
|
||||||
|
* Les champs absents ne laissent pas de trou — ils ne s'affichent simplement pas.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TrackDetails(entry: MediaEntry, queueLabel: String?) {
|
||||||
|
val details = buildList {
|
||||||
|
queueLabel?.takeIf { it.isNotBlank() }?.let { add("📋 $it") }
|
||||||
|
add(entry.source.label)
|
||||||
|
entry.sizeBytes?.takeIf { it > 0 }?.let { add(formatBytes(it)) }
|
||||||
|
entry.addedAt.takeIf { it > 0 }?.let {
|
||||||
|
add(SimpleDateFormat("d MMM yyyy", Locale.FRANCE).format(Date(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = details.joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OfflineToggle(
|
private fun OfflineToggle(
|
||||||
entry: MediaEntry?,
|
entry: MediaEntry?,
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.playlists
|
package fr.tuxgyver.noxely.ui.playlists
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -35,8 +35,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/** Choix de la playlist d'accueil, avec création à la volée si aucune ne convient. */
|
/** Choix de la playlist d'accueil, avec création à la volée si aucune ne convient. */
|
||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.playlists
|
package fr.tuxgyver.noxely.ui.playlists
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -33,9 +33,9 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.cachette.ui.components.MediaRow
|
import fr.tuxgyver.noxely.ui.components.MediaRow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/** Le contenu d'une playlist, dans l'ordre d'ajout. */
|
/** Le contenu d'une playlist, dans l'ordre d'ajout. */
|
||||||
@@ -46,7 +46,7 @@ fun PlaylistDetailScreen(
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
currentMediaId: String?,
|
currentMediaId: String?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
onPlay: (List<MediaEntry>, Int, String?) -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
) {
|
) {
|
||||||
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
val playlists by container.playlists.playlists.collectAsStateWithLifecycle()
|
||||||
@@ -81,7 +81,7 @@ fun PlaylistDetailScreen(
|
|||||||
|
|
||||||
if (tracks.isNotEmpty()) {
|
if (tracks.isNotEmpty()) {
|
||||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
Row(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||||
Button(onClick = { onPlay(tracks, 0) }) {
|
Button(onClick = { onPlay(tracks, 0, playlist?.name) }) {
|
||||||
Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Rounded.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text("Tout lire")
|
Text("Tout lire")
|
||||||
@@ -115,7 +115,7 @@ fun PlaylistDetailScreen(
|
|||||||
entry = entry,
|
entry = entry,
|
||||||
offline = offlineStates[entry.id],
|
offline = offlineStates[entry.id],
|
||||||
isCurrent = entry.id == currentMediaId,
|
isCurrent = entry.id == currentMediaId,
|
||||||
onClick = { onPlay(tracks, tracks.indexOf(entry)) },
|
onClick = { onPlay(tracks, tracks.indexOf(entry), playlist?.name) },
|
||||||
onDownload = { container.downloads.download(entry) },
|
onDownload = { container.downloads.download(entry) },
|
||||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.playlists
|
package fr.tuxgyver.noxely.ui.playlists
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -39,7 +39,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package fr.tuxgyver.noxely.ui.radio
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.FiberManualRecord
|
||||||
|
import androidx.compose.material.icons.rounded.Alarm
|
||||||
|
import androidx.compose.material.icons.rounded.GraphicEq
|
||||||
|
import androidx.compose.material.icons.rounded.PlayCircleOutline
|
||||||
|
import androidx.compose.material.icons.rounded.Radar
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
import androidx.compose.material.icons.rounded.Stop
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
|
import fr.tuxgyver.noxely.radio.RADIO_GENRES
|
||||||
|
import fr.tuxgyver.noxely.radio.RadioStation
|
||||||
|
import fr.tuxgyver.noxely.radio.Recorder
|
||||||
|
import fr.tuxgyver.noxely.radio.RecordingService
|
||||||
|
import fr.tuxgyver.noxely.ui.components.Artwork
|
||||||
|
import fr.tuxgyver.noxely.ui.components.formatBytes
|
||||||
|
import fr.tuxgyver.noxely.ui.components.formatDuration
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les stations les plus écoutées, à écouter en direct ou à enregistrer.
|
||||||
|
*
|
||||||
|
* L'enregistrement produit un fichier local qui rejoint la bibliothèque : il n'a donc rien à
|
||||||
|
* télécharger ensuite, il est déjà hors connexion.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RadioScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
currentMediaId: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
onOpenWatches: () -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val status by Recorder.status.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var genre by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var stations by remember { mutableStateOf<List<RadioStation>>(emptyList()) }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var nowPlaying by remember { mutableStateOf(emptyMap<String, String>()) }
|
||||||
|
var scheduleFor by remember { mutableStateOf<RadioStation?>(null) }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
val outcome = if (query.isBlank()) container.radio.popular(genre)
|
||||||
|
else container.radio.search(query)
|
||||||
|
outcome.fold(
|
||||||
|
onSuccess = { stations = it; if (it.isEmpty()) error = "Aucune station" },
|
||||||
|
onFailure = { error = it.message ?: "Annuaire indisponible" },
|
||||||
|
)
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(genre) { load() }
|
||||||
|
|
||||||
|
// Aucun annuaire n'expose le morceau en cours : il faut ouvrir chaque flux. On s'en tient
|
||||||
|
// donc aux premières stations affichées, quatre à la fois, et le résultat est conservé.
|
||||||
|
LaunchedEffect(stations) {
|
||||||
|
nowPlaying = emptyMap()
|
||||||
|
stations.take(PROBED_STATIONS).chunked(4).forEach { group ->
|
||||||
|
val found = group.map { station ->
|
||||||
|
async { station.id to container.radio.nowPlaying(station.streamUrl) }
|
||||||
|
}.awaitAll()
|
||||||
|
nowPlaying = nowPlaying + found.mapNotNull { (id, title) ->
|
||||||
|
title?.let { id to it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Radio") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onOpenWatches) {
|
||||||
|
Icon(Icons.Rounded.Radar, contentDescription = "Morceaux guettés")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (status.isRecording) {
|
||||||
|
// La progression n'est publiée que tous les 256 Ko : le chronomètre se rafraîchit
|
||||||
|
// donc de lui-même, sinon il semblerait figé entre deux paliers.
|
||||||
|
var tick by remember { mutableStateOf(0L) }
|
||||||
|
LaunchedEffect(status.startedAt) {
|
||||||
|
while (true) {
|
||||||
|
tick = System.currentTimeMillis()
|
||||||
|
delay(1_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RecordingBanner(
|
||||||
|
station = status.station!!,
|
||||||
|
title = status.currentTitle,
|
||||||
|
waitingFor = status.waitingFor,
|
||||||
|
elapsedMs = (tick - status.startedAt).coerceAtLeast(0L),
|
||||||
|
bytes = status.bytes,
|
||||||
|
onStop = {
|
||||||
|
RecordingService.stop(context)
|
||||||
|
onMessage("Enregistrement versé dans la bibliothèque")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
placeholder = { Text("Rechercher une station") },
|
||||||
|
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { load() }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = genre == null,
|
||||||
|
onClick = { genre = null; query = "" },
|
||||||
|
label = { Text("Les plus écoutées") },
|
||||||
|
)
|
||||||
|
RADIO_GENRES.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = genre == option,
|
||||||
|
onClick = { genre = option; query = "" },
|
||||||
|
label = { Text(option.replaceFirstChar(Char::uppercase)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
error != null -> Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error!!,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> LazyColumn(
|
||||||
|
contentPadding = listPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(stations, key = { it.id }) { station ->
|
||||||
|
StationRow(
|
||||||
|
station = station,
|
||||||
|
nowPlaying = nowPlaying[station.id],
|
||||||
|
playing = station.asEntry().id == currentMediaId,
|
||||||
|
recording = status.station?.id == station.id,
|
||||||
|
onSchedule = { scheduleFor = station },
|
||||||
|
onListen = {
|
||||||
|
val entry = station.asEntry()
|
||||||
|
container.library.preview(listOf(entry))
|
||||||
|
container.player.play(listOf(entry), 0)
|
||||||
|
},
|
||||||
|
onRecord = {
|
||||||
|
if (status.isRecording) {
|
||||||
|
onMessage("Un enregistrement est déjà en cours")
|
||||||
|
} else {
|
||||||
|
RecordingService.start(context, station)
|
||||||
|
onMessage("Enregistrement de ${station.name} démarré")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(24.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleFor?.let { station ->
|
||||||
|
ScheduleDialog(
|
||||||
|
station = station,
|
||||||
|
onDismiss = { scheduleFor = null },
|
||||||
|
onConfirm = { wanted ->
|
||||||
|
RecordingService.start(context, station, whenTitleContains = wanted)
|
||||||
|
onMessage("Noxely guette « $wanted » sur ${station.name}")
|
||||||
|
scheduleFor = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Au-delà, le nombre de connexions simultanées ne se justifierait plus. */
|
||||||
|
private const val PROBED_STATIONS = 12
|
||||||
|
|
||||||
|
/** Une station écoutée en direct est un média éphémère : pas de durée, pas de mise en cache. */
|
||||||
|
private fun RadioStation.asEntry() = MediaEntry(
|
||||||
|
id = "radio:$id",
|
||||||
|
source = SourceKind.URL,
|
||||||
|
kind = MediaKind.AUDIO,
|
||||||
|
title = name,
|
||||||
|
subtitle = subtitle.ifBlank { "Radio en direct" },
|
||||||
|
uri = streamUrl,
|
||||||
|
artworkUri = faviconUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Programme un enregistrement : il démarrera dès que la station annoncera ce titre. */
|
||||||
|
@Composable
|
||||||
|
private fun ScheduleDialog(
|
||||||
|
station: RadioStation,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var wanted by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Guetter un titre") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Noxely écoutera ${station.name} et déclenchera l'enregistrement dès " +
|
||||||
|
"qu'un titre contenant ce texte sera annoncé, puis s'arrêtera à la fin du morceau.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = wanted,
|
||||||
|
onValueChange = { wanted = it },
|
||||||
|
placeholder = { Text("Titre ou artiste") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(enabled = wanted.isNotBlank(), onClick = { onConfirm(wanted.trim()) }) {
|
||||||
|
Text("Guetter")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordingBanner(
|
||||||
|
station: RadioStation,
|
||||||
|
title: String?,
|
||||||
|
waitingFor: String?,
|
||||||
|
elapsedMs: Long,
|
||||||
|
bytes: Long,
|
||||||
|
onStop: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.FiberManualRecord,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFD32F2F),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = waitingFor?.let { "En attente de « $it »" } ?: title ?: station.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = listOfNotNull(
|
||||||
|
title?.let { station.name },
|
||||||
|
formatDuration(elapsedMs),
|
||||||
|
formatBytes(bytes),
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onStop) {
|
||||||
|
Icon(Icons.Rounded.Stop, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Arrêter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StationRow(
|
||||||
|
station: RadioStation,
|
||||||
|
nowPlaying: String?,
|
||||||
|
playing: Boolean,
|
||||||
|
recording: Boolean,
|
||||||
|
onListen: () -> Unit,
|
||||||
|
onRecord: () -> Unit,
|
||||||
|
onSchedule: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (playing) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surface,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onListen),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Artwork(
|
||||||
|
uri = station.faviconUrl,
|
||||||
|
title = station.name,
|
||||||
|
kind = MediaKind.AUDIO,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = station.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = nowPlaying ?: station.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (nowPlaying != null) MaterialTheme.colorScheme.secondary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline,
|
||||||
|
contentDescription = if (playing) "En écoute" else "Écouter en direct",
|
||||||
|
tint = if (playing) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
IconButton(onClick = onSchedule) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Alarm,
|
||||||
|
contentDescription = "Enregistrer quand un titre passe",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRecord) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.FiberManualRecord,
|
||||||
|
contentDescription = "Enregistrer cette station",
|
||||||
|
tint = if (recording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package fr.tuxgyver.noxely.ui.radio
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.Add
|
||||||
|
import androidx.compose.material.icons.rounded.CheckCircle
|
||||||
|
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||||
|
import androidx.compose.material.icons.rounded.HourglassEmpty
|
||||||
|
import androidx.compose.material.icons.rounded.Radar
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
|
import fr.tuxgyver.noxely.radio.Watch
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les morceaux guettés sur l'ensemble des stations : ceux qu'on attend, et ceux qui sont passés.
|
||||||
|
*
|
||||||
|
* Le balayage automatique tourne au quart d'heure — le minimum qu'autorise le système. Un morceau
|
||||||
|
* durant trois à quatre minutes, il en manque forcément : le bouton « Balayer » sert à regarder
|
||||||
|
* tout de suite, quand on sait qu'un titre tourne.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WatchListScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val watches by container.watches.watches.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
var scanning by remember { mutableStateOf(false) }
|
||||||
|
val watching by fr.tuxgyver.noxely.radio.WatchService.isRunning.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val pending = watches.filterNot { it.isFound }
|
||||||
|
val found = watches.filter { it.isFound }
|
||||||
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Guets") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (scanning) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
modifier = Modifier.size(22.dp).padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
} else if (pending.isNotEmpty()) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scanning = true
|
||||||
|
scope.launch {
|
||||||
|
val outcome = runCatching { container.watchScanner.scan(context) }.getOrNull()
|
||||||
|
scanning = false
|
||||||
|
onMessage(
|
||||||
|
when {
|
||||||
|
outcome == null -> "Balayage impossible"
|
||||||
|
outcome.matched != null ->
|
||||||
|
"Trouvé sur ${outcome.matchedOn} : ${outcome.matched}"
|
||||||
|
else ->
|
||||||
|
"${outcome.stationsProbed} stations écoutées, ${outcome.titlesRead} titres lus, rien de guetté"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Rounded.Radar, contentDescription = "Balayer maintenant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pending.isNotEmpty()) {
|
||||||
|
Surface(
|
||||||
|
color = if (watching) MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(14.dp)) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = if (watching) "Veille active" else "Veille en arrière-plan",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (watching) {
|
||||||
|
"Les stations sont balayées toutes les 3 minutes, écran éteint compris."
|
||||||
|
} else {
|
||||||
|
"Sans elle, le balayage n'a lieu qu'au quart d'heure : un morceau de " +
|
||||||
|
"trois minutes passe souvent entre deux."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
if (watching) fr.tuxgyver.noxely.radio.WatchService.stop(context)
|
||||||
|
else fr.tuxgyver.noxely.radio.WatchService.start(context)
|
||||||
|
}) {
|
||||||
|
Text(if (watching) "Arrêter" else "Activer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
placeholder = { Text("Titre ou artiste à guetter") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
enabled = query.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
// La valeur est capturée avant d'être effacée : la coroutine lirait sinon
|
||||||
|
// un champ déjà vidé, et n'enregistrerait rien.
|
||||||
|
val wanted = query.trim()
|
||||||
|
scope.launch { container.watches.add(wanted) }
|
||||||
|
onMessage("« $wanted » est guetté sur toutes les stations")
|
||||||
|
query = ""
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = listPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
if (watches.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Nommez un morceau : Noxely écoutera les stations les plus suivies " +
|
||||||
|
"et l'enregistrera dès qu'il passera.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.isNotEmpty()) {
|
||||||
|
item { SectionTitle("En attente (${pending.size})") }
|
||||||
|
items(pending, key = { it.id }) { watch ->
|
||||||
|
WatchRow(watch) { scope.launch { container.watches.remove(watch.id) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found.isNotEmpty()) {
|
||||||
|
item { SectionTitle("Trouvés (${found.size})") }
|
||||||
|
items(found, key = { it.id }) { watch ->
|
||||||
|
WatchRow(watch) { scope.launch { container.watches.remove(watch.id) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Spacer(Modifier.size(24.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(text: String) {
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.titleSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WatchRow(watch: Watch, onRemove: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
color = if (watch.isFound) MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surface,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
tonalElevation = 1.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (watch.isFound) Icons.Rounded.CheckCircle else Icons.Rounded.HourglassEmpty,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (watch.isFound) MaterialTheme.colorScheme.secondary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = watch.query,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (watch.isFound) {
|
||||||
|
"Capté sur ${watch.foundOn} · ${watch.foundTitle?.take(48).orEmpty()}"
|
||||||
|
} else {
|
||||||
|
val last = watch.lastScanAt
|
||||||
|
?.let { SimpleDateFormat("HH:mm", Locale.FRANCE).format(Date(it)) }
|
||||||
|
"${watch.scans} balayage(s)" + (last?.let { ", dernier à $it" } ?: ", pas encore balayé")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(Icons.Rounded.DeleteOutline, contentDescription = "Retirer ce guet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-4
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.sources
|
package fr.tuxgyver.noxely.ui.sources
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -16,8 +16,11 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||||
|
import androidx.compose.material.icons.rounded.Headphones
|
||||||
|
import androidx.compose.material.icons.rounded.Info
|
||||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||||
import androidx.compose.material.icons.rounded.Public
|
import androidx.compose.material.icons.rounded.Public
|
||||||
|
import androidx.compose.material.icons.rounded.Radio
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Info
|
import androidx.compose.material.icons.rounded.Info
|
||||||
import androidx.compose.material.icons.rounded.Link
|
import androidx.compose.material.icons.rounded.Link
|
||||||
@@ -47,9 +50,9 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.model.SourceKind
|
import fr.tuxgyver.noxely.model.SourceKind
|
||||||
import fr.tuxgyver.cachette.sources.CatalogId
|
import fr.tuxgyver.noxely.sources.CatalogId
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -59,6 +62,8 @@ fun SourcesScreen(
|
|||||||
onMessage: (String) -> Unit,
|
onMessage: (String) -> Unit,
|
||||||
onBrowseSource: (SourceKind) -> Unit,
|
onBrowseSource: (SourceKind) -> Unit,
|
||||||
onBrowseCatalog: (CatalogId) -> Unit,
|
onBrowseCatalog: (CatalogId) -> Unit,
|
||||||
|
onBrowseRadio: () -> Unit,
|
||||||
|
onOpenAbout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
var sheet by remember { mutableStateOf<ImportKind?>(null) }
|
var sheet by remember { mutableStateOf<ImportKind?>(null) }
|
||||||
@@ -122,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 {
|
item {
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
|
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
|
||||||
@@ -137,6 +155,7 @@ fun SourcesScreen(
|
|||||||
icon = when (catalog) {
|
icon = when (catalog) {
|
||||||
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
|
CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook
|
||||||
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
|
CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic
|
||||||
|
CatalogId.UNDERGROUND -> Icons.Rounded.Headphones
|
||||||
else -> Icons.Rounded.Public
|
else -> Icons.Rounded.Public
|
||||||
},
|
},
|
||||||
title = catalog.title,
|
title = catalog.title,
|
||||||
@@ -159,6 +178,18 @@ fun SourcesScreen(
|
|||||||
UpdateCard(container)
|
UpdateCard(container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SourceCard(
|
||||||
|
icon = Icons.Rounded.Info,
|
||||||
|
title = "À propos",
|
||||||
|
status = "Version ${fr.tuxgyver.noxely.BuildConfig.VERSION_NAME}",
|
||||||
|
description = "Ce que fait Noxely, ce qu'elle garantit, avec quoi elle est faite et qui en répond.",
|
||||||
|
actionLabel = "Ouvrir",
|
||||||
|
onAction = onOpenAbout,
|
||||||
|
onOpen = onOpenAbout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
unavailableSourceCards(
|
unavailableSourceCards(
|
||||||
listOf(
|
listOf(
|
||||||
UnavailableSource(
|
UnavailableSource(
|
||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.sources
|
package fr.tuxgyver.noxely.ui.sources
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -24,10 +24,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import fr.tuxgyver.cachette.AppContainer
|
import fr.tuxgyver.noxely.AppContainer
|
||||||
import fr.tuxgyver.cachette.BuildConfig
|
import fr.tuxgyver.noxely.BuildConfig
|
||||||
import fr.tuxgyver.cachette.update.UpdateInfo
|
import fr.tuxgyver.noxely.update.UpdateInfo
|
||||||
import fr.tuxgyver.cachette.update.UpdateNotifier
|
import fr.tuxgyver.noxely.update.UpdateNotifier
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ fun UpdateCard(container: AppContainer) {
|
|||||||
Text(
|
Text(
|
||||||
text = when (current) {
|
text = when (current) {
|
||||||
UpdateUiState.Idle ->
|
UpdateUiState.Idle ->
|
||||||
"Cachette regarde chaque jour si une nouvelle version a été publiée, et la télécharge en Wi-Fi."
|
"Noxely regarde chaque jour si une nouvelle version a été publiée, et la télécharge en Wi-Fi."
|
||||||
UpdateUiState.Checking -> "Vérification en cours…"
|
UpdateUiState.Checking -> "Vérification en cours…"
|
||||||
UpdateUiState.UpToDate -> "Vous avez déjà la dernière version."
|
UpdateUiState.UpToDate -> "Vous avez déjà la dernière version."
|
||||||
is UpdateUiState.Available -> "Version ${current.info.version} disponible."
|
is UpdateUiState.Available -> "Version ${current.info.version} disponible."
|
||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.ui.theme
|
package fr.tuxgyver.noxely.ui.theme
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -57,7 +57,7 @@ private val DarkColors = darkColorScheme(
|
|||||||
outlineVariant = Color(0xFF3A3048),
|
outlineVariant = Color(0xFF3A3048),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val CachetteShapes = Shapes(
|
private val NoxelyShapes = Shapes(
|
||||||
extraSmall = RoundedCornerShape(8.dp),
|
extraSmall = RoundedCornerShape(8.dp),
|
||||||
small = RoundedCornerShape(14.dp),
|
small = RoundedCornerShape(14.dp),
|
||||||
medium = RoundedCornerShape(20.dp),
|
medium = RoundedCornerShape(20.dp),
|
||||||
@@ -65,7 +65,7 @@ private val CachetteShapes = Shapes(
|
|||||||
extraLarge = RoundedCornerShape(36.dp),
|
extraLarge = RoundedCornerShape(36.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val CachetteTypography = Typography().run {
|
private val NoxelyTypography = Typography().run {
|
||||||
copy(
|
copy(
|
||||||
displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp),
|
displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp),
|
||||||
headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.4).sp),
|
headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.4).sp),
|
||||||
@@ -77,7 +77,7 @@ private val CachetteTypography = Typography().run {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CachetteTheme(
|
fun NoxelyTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -87,8 +87,8 @@ fun CachetteTheme(
|
|||||||
val colors = if (darkTheme) DarkColors else LightColors
|
val colors = if (darkTheme) DarkColors else LightColors
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colors,
|
colorScheme = colors,
|
||||||
shapes = CachetteShapes,
|
shapes = NoxelyShapes,
|
||||||
typography = CachetteTypography,
|
typography = NoxelyTypography,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.update
|
package fr.tuxgyver.noxely.update
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
@@ -8,7 +8,7 @@ import androidx.work.NetworkType
|
|||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import fr.tuxgyver.cachette.CachetteApp
|
import fr.tuxgyver.noxely.NoxelyApp
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +21,7 @@ class UpdateCheckWorker(
|
|||||||
) : CoroutineWorker(context, params) {
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val repository = (applicationContext as CachetteApp).container.updates
|
val repository = (applicationContext as NoxelyApp).container.updates
|
||||||
val update = runCatching { repository.checkForUpdate() }.getOrNull()
|
val update = runCatching { repository.checkForUpdate() }.getOrNull()
|
||||||
?: return Result.success()
|
?: return Result.success()
|
||||||
val apk = runCatching { repository.downloadApk(update) }.getOrNull()
|
val apk = runCatching { repository.downloadApk(update) }.getOrNull()
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
package fr.tuxgyver.cachette.update
|
package fr.tuxgyver.noxely.update
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -12,7 +12,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import fr.tuxgyver.cachette.R
|
import fr.tuxgyver.noxely.R
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/** Prévient que la mise à jour est téléchargée et ouvre l'installateur système d'un appui. */
|
/** Prévient que la mise à jour est téléchargée et ouvre l'installateur système d'un appui. */
|
||||||
@@ -38,7 +38,7 @@ object UpdateNotifier {
|
|||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_offline)
|
.setSmallIcon(R.drawable.ic_offline)
|
||||||
.setContentTitle("Cachette $version est prête")
|
.setContentTitle("Noxely $version est prête")
|
||||||
.setContentText("Appuyez pour installer la mise à jour.")
|
.setContentText("Appuyez pour installer la mise à jour.")
|
||||||
.setContentIntent(pending)
|
.setContentIntent(pending)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -73,7 +73,7 @@ object UpdateNotifier {
|
|||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Mises à jour",
|
"Mises à jour",
|
||||||
NotificationManager.IMPORTANCE_DEFAULT,
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
).apply { description = "Disponibilité d'une nouvelle version de Cachette" }
|
).apply { description = "Disponibilité d'une nouvelle version de Noxely" }
|
||||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
package fr.tuxgyver.cachette.update
|
package fr.tuxgyver.noxely.update
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import fr.tuxgyver.cachette.BuildConfig
|
import fr.tuxgyver.noxely.BuildConfig
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -103,7 +103,7 @@ class UpdateRepository(
|
|||||||
instanceFollowRedirects = true
|
instanceFollowRedirects = true
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = 60_000
|
readTimeout = 60_000
|
||||||
setRequestProperty("User-Agent", "Cachette/$currentVersion (Android)")
|
setRequestProperty("User-Agent", "Noxely/$currentVersion (Android)")
|
||||||
setRequestProperty("Authorization", "token ${BuildConfig.GITEA_UPDATE_TOKEN}")
|
setRequestProperty("Authorization", "token ${BuildConfig.GITEA_UPDATE_TOKEN}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<!-- Note de musique posée sur une flèche descendante : « mis de côté, hors ligne ». -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFB79CFF"
|
|
||||||
android:pathData="M64,28 L64,58.5 a10,10 0 1,1 -6,-9.2 L58,37.5 L44,40.6 L44,64 a10,10 0 1,1 -6,-9.2 L38,35.8 L64,28 Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF7CE7D6"
|
|
||||||
android:pathData="M54,70 L54,80 L47,80 L54,88 L61,80 L54,80 Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.Cachette" parent="android:Theme.Material.NoActionBar">
|
<style name="Theme.Noxely" parent="android:Theme.Material.NoActionBar">
|
||||||
<item name="android:windowBackground">@color/splash_background</item>
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="splash_background">#FFF7F2FA</color>
|
<color name="splash_background">#FFF7F2FA</color>
|
||||||
<color name="ic_launcher_background">#FF1B1033</color>
|
<color name="ic_launcher_background">#FF0E9CC7</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Cachette</string>
|
<string name="app_name">Noxely</string>
|
||||||
<string name="download_channel_name">Téléchargements</string>
|
<string name="download_channel_name">Téléchargements</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.Cachette" parent="android:Theme.Material.Light.NoActionBar">
|
<style name="Theme.Noxely" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
<item name="android:windowBackground">@color/splash_background</item>
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
|||||||
+1
-1
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Cachette"
|
rootProject.name = "Noxely"
|
||||||
include(":app")
|
include(":app")
|
||||||
Reference in new issue
Block a user