Première version : lecteur audio et vidéo hors connexion (1.0.0)
Cachette met des médias de côté pendant qu'il y a du réseau, pour les lire quand il n'y en a plus. - Lecture audio et vidéo en arrière-plan (Media3 MediaSessionService) - Mise en cache explicite, reprise automatique après coupure (WorkManager) - Sources : appareil (MediaStore), lien direct, flux RSS de podcast - Catalogues libres de droits : Internet Archive et LibriVox - Parcours d'une source avec sélection multiple et téléchargement en lot - Interface Jetpack Compose Material 3, thèmes clair et sombre - Makefile catégorisé, publication Gitea déléguée à BorisSkill Le fichier VERSION est la source unique de la version : versionCode en est déduit et la publication s'y réfère.
This commit is contained in:
commit
992fe88cc4
59 files changed
+4619
No files matched your search
+13
@@ -0,0 +1,13 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
keystore.properties
|
||||||
|
*.jks
|
||||||
|
.gitea-token
|
||||||
|
Cachette-*.apk
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Toutes les évolutions notables de Cachette sont documentées ici.
|
||||||
|
|
||||||
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||||
|
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-09-11
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- Lecture audio et vidéo en arrière-plan (Media3 `MediaSessionService`), avec notification
|
||||||
|
système, commandes casque et écran verrouillé.
|
||||||
|
- Mise en cache hors connexion explicite : téléchargement à la demande, reprise automatique
|
||||||
|
après coupure réseau ou redémarrage via WorkManager.
|
||||||
|
- Bibliothèque unifiée mêlant le contenu de l'appareil (MediaStore) et les médias importés.
|
||||||
|
- Sources : appareil, lien direct `http(s)`, flux RSS de podcast.
|
||||||
|
- Catalogues libres de droits intégrés : Internet Archive et LibriVox, sans compte ni clé d'API.
|
||||||
|
- Parcours d'une source avec sélection multiple et téléchargement en lot.
|
||||||
|
- Écran « Hors ligne » : taille du cache, progression, libération élément par élément ou en bloc.
|
||||||
|
- Interface Jetpack Compose Material 3, thème clair et sombre, palette de marque.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# --- Outillage -------------------------------------------------------------
|
||||||
|
# Le SDK et adb sont rarement dans le PATH : on les retrouve sans les figer.
|
||||||
|
ANDROID_SDK ?= $(HOME)/Android/Sdk
|
||||||
|
ADB ?= $(shell command -v adb 2>/dev/null || echo $(ANDROID_SDK)/platform-tools/adb)
|
||||||
|
GRADLE := ./gradlew
|
||||||
|
|
||||||
|
VERSION := $(shell tr -d '[:space:]' < VERSION)
|
||||||
|
|
||||||
|
APPLICATION_ID := fr.tuxgyver.cachette
|
||||||
|
LAUNCH_ACTIVITY := $(APPLICATION_ID)/.MainActivity
|
||||||
|
DEBUG_APK := app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
# Le nom de l'APK de release dépend de la signature (app-release.apk contre
|
||||||
|
# app-release-unsigned.apk) : on liste le répertoire au lieu de deviner.
|
||||||
|
RELEASE_DIR := app/build/outputs/apk/release
|
||||||
|
SIGNED_APK := $(RELEASE_DIR)/app-release.apk
|
||||||
|
OUTPUT_APK := Cachette-$(VERSION).apk
|
||||||
|
|
||||||
|
# --- Publication -----------------------------------------------------------
|
||||||
|
# Déléguée au script de BorisSkill, qui sait déjà tout faire : résolution du
|
||||||
|
# jeton ($GITEA_TOKEN, .gitea-token, ~/.config/gitea/token, ~/.git-credentials),
|
||||||
|
# déduction de l'hôte, du propriétaire et du dépôt depuis le remote 'origin',
|
||||||
|
# création et poussée du tag, extraction des notes depuis le CHANGELOG.
|
||||||
|
GITEA_RELEASE ?= $(HOME)/.claude/skills/BorisSkill/scripts/gitea_release.sh
|
||||||
|
|
||||||
|
##@ 🔨 Construction
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Construit l'APK de debug
|
||||||
|
$(GRADLE) :app:assembleDebug
|
||||||
|
@echo "→ $(DEBUG_APK)"
|
||||||
|
|
||||||
|
.PHONY: release
|
||||||
|
release: ## Construit l'APK de release (signé si keystore.properties existe)
|
||||||
|
@test -f keystore.properties || echo "⚠️ keystore.properties absent : l'APK produit sera non signé"
|
||||||
|
$(GRADLE) :app:assembleRelease
|
||||||
|
@if [ -f $(SIGNED_APK) ]; then \
|
||||||
|
cp $(SIGNED_APK) $(OUTPUT_APK); \
|
||||||
|
echo "→ $(OUTPUT_APK)"; \
|
||||||
|
else \
|
||||||
|
ls -1 $(RELEASE_DIR)/*.apk 2>/dev/null | sed 's/^/→ /'; \
|
||||||
|
echo " (non signé : non publiable, voir keystore.properties)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
.PHONY: apk
|
||||||
|
apk: ## Affiche le chemin et la taille des APK construits
|
||||||
|
@ls -lh $(DEBUG_APK) 2>/dev/null || echo "APK de debug : pas encore construit (make build)"
|
||||||
|
@ls -lh $(RELEASE_DIR)/*.apk 2>/dev/null || echo "APK de release : pas encore construit (make release)"
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean: ## Supprime les artefacts de build
|
||||||
|
$(GRADLE) clean
|
||||||
|
|
||||||
|
##@ 🚀 Déploiement
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install: ## Installe l'APK de debug sur l'appareil connecté
|
||||||
|
$(GRADLE) :app:installDebug
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run: install ## Installe puis lance l'application
|
||||||
|
$(ADB) shell am start -n $(LAUNCH_ACTIVITY)
|
||||||
|
|
||||||
|
.PHONY: uninstall
|
||||||
|
uninstall: ## Désinstalle l'application de l'appareil connecté
|
||||||
|
$(ADB) uninstall $(APPLICATION_ID)
|
||||||
|
|
||||||
|
##@ 🧪 Qualité
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint: ## Analyse Android Lint (rapport dans app/build/reports/lint-results-debug.html)
|
||||||
|
$(GRADLE) :app:lintDebug
|
||||||
|
|
||||||
|
##@ 🐞 Diagnostic
|
||||||
|
|
||||||
|
.PHONY: devices
|
||||||
|
devices: ## Liste les appareils et émulateurs connectés
|
||||||
|
$(ADB) devices -l
|
||||||
|
|
||||||
|
.PHONY: logcat
|
||||||
|
logcat: ## Suit les journaux de l'application (Ctrl+C pour arrêter)
|
||||||
|
$(ADB) logcat --pid=$$($(ADB) shell pidof $(APPLICATION_ID))
|
||||||
|
|
||||||
|
.PHONY: crash
|
||||||
|
crash: ## Affiche la dernière trace de plantage éventuelle
|
||||||
|
@$(ADB) logcat -d -v brief | grep -A 30 -E "FATAL EXCEPTION" | tail -40 || echo "Aucun plantage enregistré"
|
||||||
|
|
||||||
|
##@ 📤 Publication
|
||||||
|
|
||||||
|
.PHONY: publish
|
||||||
|
publish: _check-apk _check-release-script ## Publie l'APK signé en release Gitea (tag + notes + asset)
|
||||||
|
bash $(GITEA_RELEASE) --version $(VERSION) --asset $(OUTPUT_APK)
|
||||||
|
|
||||||
|
.PHONY: publish-dry
|
||||||
|
publish-dry: _check-apk _check-release-script ## Simule la publication sans rien envoyer
|
||||||
|
bash $(GITEA_RELEASE) --version $(VERSION) --asset $(OUTPUT_APK) --dry-run
|
||||||
|
|
||||||
|
.PHONY: deploy
|
||||||
|
deploy: release publish ## Construit l'APK signé puis le publie
|
||||||
|
|
||||||
|
.PHONY: _check-release-script
|
||||||
|
_check-release-script:
|
||||||
|
@test -f $(GITEA_RELEASE) || \
|
||||||
|
{ echo "❌ Script de publication introuvable : $(GITEA_RELEASE)"; \
|
||||||
|
echo " Surchargez le chemin : make publish GITEA_RELEASE=/chemin/gitea_release.sh"; exit 1; }
|
||||||
|
|
||||||
|
.PHONY: _check-apk
|
||||||
|
_check-apk:
|
||||||
|
@test -f $(OUTPUT_APK) || \
|
||||||
|
{ echo "❌ $(OUTPUT_APK) introuvable — lancez d'abord 'make release' (signature requise)."; exit 1; }
|
||||||
|
@test $(OUTPUT_APK) -nt VERSION || \
|
||||||
|
{ echo "❌ $(OUTPUT_APK) est plus ancien que VERSION — relancez 'make release'."; exit 1; }
|
||||||
|
|
||||||
|
##@ ℹ️ Aide
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Affiche cette aide
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} \
|
||||||
|
/^[a-zA-Z0-9_.-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \
|
||||||
|
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)
|
||||||
|
@echo ""
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# 🎧 Cachette
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
[](https://developer.android.com)
|
||||||
|
[](https://kotlinlang.org)
|
||||||
|
[](https://developer.android.com/jetpack/compose)
|
||||||
|
[](https://developer.android.com/media/media3)
|
||||||
|
[](VERSION)
|
||||||
|
[](#-comment-ça-marche)
|
||||||
|
|
||||||
|
Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
|
||||||
|
forfaits épuisés. On choisit ce qu'on emporte, l'application le télécharge, et tout reste lisible
|
||||||
|
sans la moindre connexion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📸 Aperçu
|
||||||
|
|
||||||
|
| Bibliothèque | Lecture | Hors ligne | Catalogues libres |
|
||||||
|
|:---:|:---:|:---:|:---:|
|
||||||
|
|  |  |  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Fonctionnalités
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 🎵 **Audio et vidéo** | Un seul lecteur, une seule file d'attente, la vidéo bascule sur une surface dédiée |
|
||||||
|
| 📥 **Mise en cache explicite** | Rien n'est téléchargé sans un geste : vous décidez quoi garder |
|
||||||
|
| 🔍 **Parcours des sources** | On entre dans une source, on coche ce qu'on veut, on télécharge en lot |
|
||||||
|
| 🎼 **Catalogues libres de droits** | Recherche intégrée dans Internet Archive et LibriVox, sans compte ni clé d'API |
|
||||||
|
| 📻 **Podcasts RSS** | Un flux, ses épisodes, et le choix de ceux qu'on emporte |
|
||||||
|
| 🔗 **Liens directs** | N'importe quelle URL audio ou vidéo, y compris un serveur média auto-hébergé |
|
||||||
|
| 📱 **Contenu de l'appareil** | Lecture de la bibliothèque MediaStore, déjà disponible hors connexion par nature |
|
||||||
|
| 🎛️ **Lecture en arrière-plan** | Notification système, casque, Bluetooth, écran verrouillé |
|
||||||
|
| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti |
|
||||||
|
| 🔁 **Reprise automatique** | Un téléchargement coupé repart tout seul via WorkManager |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Sources disponibles
|
||||||
|
|
||||||
|
| Source | État | Détail |
|
||||||
|
|---|:---:|---|
|
||||||
|
| 📱 Appareil | ✅ | Musique et vidéos MediaStore |
|
||||||
|
| 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo |
|
||||||
|
| 📻 Podcast RSS | ✅ | Flux standard avec `<enclosure>` |
|
||||||
|
| 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons |
|
||||||
|
| 📚 LibriVox | ✅ | Livres audio du domaine public |
|
||||||
|
|
||||||
|
### ⚖️ Ce que Cachette ne fait pas — et pourquoi
|
||||||
|
|
||||||
|
| Service | Pourquoi |
|
||||||
|
|---|---|
|
||||||
|
| 🎵 **Spotify** | Le SDK ne donne aucun accès au flux audio. Le hors-ligne n'existe que dans l'application Spotify. Un podcast également diffusé en RSS public reste ajoutable. |
|
||||||
|
| ▶️ **YouTube** | Les conditions d'utilisation interdisent d'extraire ou de stocker les flux. Le hors-ligne passe par YouTube Premium. |
|
||||||
|
| ☁️ **Google Drive / Photos** | Techniquement faisable pour vos propres fichiers, mais cela exige un identifiant OAuth Google lié à votre compte développeur, volontairement non embarqué ici. |
|
||||||
|
|
||||||
|
C'est un choix assumé : l'application ne contourne rien, elle travaille avec les sources qui
|
||||||
|
autorisent la copie locale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Comment ça marche
|
||||||
|
|
||||||
|
```
|
||||||
|
Source ──► Bibliothèque ──► [ Garder ] ──► Cache Media3 ──► Lecture hors ligne
|
||||||
|
│
|
||||||
|
reprise auto (WorkManager)
|
||||||
|
```
|
||||||
|
|
||||||
|
Trois propriétaires de données, aucun doublon :
|
||||||
|
|
||||||
|
- **MediaStore** possède le contenu local — on le relit, on ne le recopie pas ;
|
||||||
|
- **`imported.json`** possède les seules références distantes ajoutées à la main ;
|
||||||
|
- **l'index Media3** possède l'état du cache — taille, progression, complétude.
|
||||||
|
|
||||||
|
Le cache utilise un `NoOpCacheEvictor` : il contient **exactement** ce que vous avez demandé à
|
||||||
|
garder, et rien n'en est effacé sans votre geste. La lecture lit ce cache mais n'y écrit jamais,
|
||||||
|
pour qu'une écoute en ligne ne le fasse pas gonfler à votre insu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Démarrage
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- ☕ JDK 17
|
||||||
|
- 🤖 Android SDK avec la plateforme **36** et les build-tools 36
|
||||||
|
- 📱 Un appareil ou un émulateur en **Android 8.0 (API 26)** ou plus
|
||||||
|
|
||||||
|
### Construire
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # APK de debug
|
||||||
|
make install # installe sur l'appareil connecté
|
||||||
|
make run # installe et lance
|
||||||
|
make lint # analyse Android Lint
|
||||||
|
make release # APK de release (signé si keystore.properties existe)
|
||||||
|
make publish # release Gitea : tag + notes + APK signé
|
||||||
|
make publish-dry # simulation, sans rien envoyer
|
||||||
|
make deploy # release puis publish
|
||||||
|
```
|
||||||
|
|
||||||
|
`make help` liste toutes les cibles, groupées par catégorie.
|
||||||
|
|
||||||
|
Sans `make`, les commandes Gradle équivalentes fonctionnent :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
./gradlew installDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
L'APK se trouve dans `app/build/outputs/apk/debug/app-debug.apk`.
|
||||||
|
|
||||||
|
### Signer une version de publication
|
||||||
|
|
||||||
|
Créez un `keystore.properties` à la racine — il est ignoré par git :
|
||||||
|
|
||||||
|
```properties
|
||||||
|
storeFile=cachette.jks
|
||||||
|
storePassword=...
|
||||||
|
keyAlias=cachette
|
||||||
|
keyPassword=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📤 Publier une version
|
||||||
|
|
||||||
|
`make publish` crée le tag, la release Gitea et y attache l'APK **signé**. La cible est
|
||||||
|
idempotente : relancée sur une version déjà publiée, elle réutilise la release et remplace l'asset.
|
||||||
|
`make publish-dry` répète l'opération à blanc.
|
||||||
|
|
||||||
|
La publication délègue à `gitea_release.sh` de **BorisSkill**, qui déduit l'hôte, le propriétaire
|
||||||
|
et le dépôt du remote `origin` — il n'y a donc aucun nom de dépôt à configurer ici. Le chemin du
|
||||||
|
script est surchargeable : `make publish GITEA_RELEASE=/chemin/gitea_release.sh`.
|
||||||
|
|
||||||
|
Prérequis :
|
||||||
|
|
||||||
|
| Élément | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `VERSION` | Source unique de la version — `versionCode` en est déduit (`1.0.0` → `10000`) |
|
||||||
|
| `CHANGELOG.md` | La section `## [x.y.z]` devient le corps des notes de release |
|
||||||
|
| `keystore.properties` | Signature — sans lui l'APK est `-unsigned` et refusé à la publication |
|
||||||
|
| Dépôt git | Un `origin` configuré : le tag `vx.y.z` y est poussé |
|
||||||
|
|
||||||
|
Le jeton Gitea est résolu dans l'ordre : `$GITEA_TOKEN`, `.gitea-token` à la racine du dépôt,
|
||||||
|
`~/.config/gitea/token`, puis repli sur `~/.git-credentials`. Aucun secret n'est stocké dans ce
|
||||||
|
dépôt.
|
||||||
|
|
||||||
|
Pour monter de version, éditez `VERSION` et ajoutez la section correspondante au `CHANGELOG.md` :
|
||||||
|
Gradle, le tag git et les notes de release suivront seuls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Permissions et vie privée
|
||||||
|
|
||||||
|
| Permission | Pourquoi |
|
||||||
|
|---|---|
|
||||||
|
| `READ_MEDIA_AUDIO` / `READ_MEDIA_VIDEO` | Lister la musique et les vidéos de l'appareil |
|
||||||
|
| `INTERNET` | Télécharger ce que vous avez choisi de garder |
|
||||||
|
| `POST_NOTIFICATIONS` | Commandes de lecture et progression des téléchargements |
|
||||||
|
| `FOREGROUND_SERVICE_*` | Continuer la lecture et les téléchargements écran éteint |
|
||||||
|
|
||||||
|
Aucune donnée ne quitte l'appareil : pas de compte, pas de télémétrie, pas de sauvegarde cloud
|
||||||
|
(`dataExtractionRules` exclut explicitement le contenu mis en cache). Les seules connexions
|
||||||
|
sortantes sont celles des URL que vous ajoutez et des catalogues que vous interrogez.
|
||||||
|
|
||||||
|
> ⚠️ `usesCleartextTraffic` est activé pour permettre l'usage d'un serveur média auto-hébergé en
|
||||||
|
> `http://` sur le réseau local. C'est un compromis assumé ; préférez `https` dès que possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/fr/tuxgyver/cachette/
|
||||||
|
├── CachetteApp.kt Application + conteneur de dépendances (assemblé à la main)
|
||||||
|
├── MainActivity.kt
|
||||||
|
├── model/ MediaEntry, MediaKind, SourceKind
|
||||||
|
├── library/ MediaStore, persistance des imports, bibliothèque unifiée
|
||||||
|
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
|
||||||
|
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
||||||
|
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
|
||||||
|
└── ui/ Compose — thème, bibliothèque, hors ligne, sources, parcours, lecteur
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 État de vérification
|
||||||
|
|
||||||
|
Validé sur émulateur Pixel (Android 17, API 37) :
|
||||||
|
|
||||||
|
- ✅ Scan MediaStore audio et vidéo, vignettes comprises
|
||||||
|
- ✅ Lecture avec session média système et position qui progresse
|
||||||
|
- ✅ Import d'un flux RSS et de ses épisodes
|
||||||
|
- ✅ Téléchargement d'un épisode jusqu'à l'état « disponible hors connexion »
|
||||||
|
- ✅ Recherche Internet Archive, résolution du fichier et mise en cache
|
||||||
|
- ✅ Android Lint sans erreur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Licence
|
||||||
|
|
||||||
|
À définir par l'auteur du dépôt.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "fr.tuxgyver.cachette"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "fr.tuxgyver.cachette"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
|
||||||
|
// Le fichier VERSION est la seule source de vérité : le Makefile et la publication
|
||||||
|
// Gitea lisent le même, et versionCode s'en déduit pour rester monotone.
|
||||||
|
val appVersion = rootProject.file("VERSION").readText().trim()
|
||||||
|
val (major, minor, patch) = appVersion.split(".").map(String::toInt)
|
||||||
|
versionName = appVersion
|
||||||
|
versionCode = major * 10000 + minor * 100 + patch
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
|
||||||
|
val keystore = rootProject.file("keystore.properties")
|
||||||
|
if (keystore.exists()) {
|
||||||
|
val props = Properties().apply { keystore.inputStream().use { load(it) } }
|
||||||
|
signingConfig = signingConfigs.create("release") {
|
||||||
|
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||||
|
storePassword = props.getProperty("storePassword")
|
||||||
|
keyAlias = props.getProperty("keyAlias")
|
||||||
|
keyPassword = props.getProperty("keyPassword")
|
||||||
|
enableV2Signing = true
|
||||||
|
enableV3Signing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2025.10.00"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.8.4")
|
||||||
|
|
||||||
|
// Lecture, session média et cache hors connexion.
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.11.1")
|
||||||
|
implementation("androidx.media3:media3-session:1.11.1")
|
||||||
|
implementation("androidx.media3:media3-ui:1.11.1")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-workmanager:1.11.1")
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||||
|
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-dontwarn androidx.media3.**
|
||||||
|
-keep class androidx.media3.** { *; }
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".CachetteApp"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/Theme.Cachette">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/Theme.Cachette"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".playback.PlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".offline.CachetteDownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package fr.tuxgyver.cachette
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import fr.tuxgyver.cachette.library.ImportedStore
|
||||||
|
import fr.tuxgyver.cachette.library.LibraryRepository
|
||||||
|
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
||||||
|
import fr.tuxgyver.cachette.offline.DownloadTracker
|
||||||
|
import fr.tuxgyver.cachette.offline.OfflineModule
|
||||||
|
import fr.tuxgyver.cachette.playback.PlayerController
|
||||||
|
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
|
||||||
|
import fr.tuxgyver.cachette.sources.Catalog
|
||||||
|
import fr.tuxgyver.cachette.sources.CatalogId
|
||||||
|
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
|
||||||
|
import fr.tuxgyver.cachette.sources.RssImporter
|
||||||
|
import fr.tuxgyver.cachette.sources.UrlImporter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dépendances de l'application, assemblées à la main.
|
||||||
|
*
|
||||||
|
* Le gestionnaire de téléchargement Media3 exige le thread principal : tout est donc construit
|
||||||
|
* ici, au démarrage, et partagé tel quel avec les services.
|
||||||
|
*/
|
||||||
|
class AppContainer(context: Context) {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
|
val offline = OfflineModule(context)
|
||||||
|
val downloads = DownloadTracker(context, offline.downloadManager, scope)
|
||||||
|
|
||||||
|
val imported = ImportedStore(context, scope)
|
||||||
|
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||||
|
|
||||||
|
val urlImporter = UrlImporter()
|
||||||
|
val rssImporter = RssImporter()
|
||||||
|
|
||||||
|
val catalogs: Map<CatalogId, Catalog> = listOf(
|
||||||
|
ArchiveOrgCatalog(),
|
||||||
|
LibriVoxCatalog(rssImporter),
|
||||||
|
).associateBy { it.id }
|
||||||
|
|
||||||
|
val player = PlayerController(context, scope) { id -> library.entry(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachetteApp : Application() {
|
||||||
|
|
||||||
|
lateinit var container: AppContainer
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
container = AppContainer(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package fr.tuxgyver.cachette
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import fr.tuxgyver.cachette.ui.CachetteRoot
|
||||||
|
import fr.tuxgyver.cachette.ui.theme.CachetteTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val notificationPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* informatif */ }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val container = (application as CachetteApp).container
|
||||||
|
container.player.connect()
|
||||||
|
askForNotificationsIfNeeded()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
CachetteTheme {
|
||||||
|
CachetteRoot(container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
if (isFinishing) {
|
||||||
|
(application as CachetteApp).container.player.release()
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La notification porte les commandes de lecture et la progression des téléchargements. */
|
||||||
|
private fun askForNotificationsIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!granted) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package fr.tuxgyver.cachette.library
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistance des médias importés (liens directs, épisodes de podcast).
|
||||||
|
*
|
||||||
|
* Le contenu local vient de MediaStore et l'état de cache vient de Media3 : ce fichier ne détient
|
||||||
|
* donc que ce dont personne d'autre n'est propriétaire, la liste des références distantes ajoutées.
|
||||||
|
*/
|
||||||
|
class ImportedStore(context: Context, private val scope: CoroutineScope) {
|
||||||
|
|
||||||
|
private val file = File(context.filesDir, "imported.json")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val _entries = MutableStateFlow<List<MediaEntry>>(emptyList())
|
||||||
|
val entries: StateFlow<List<MediaEntry>> = _entries.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { _entries.value = read() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ajoute les entrées inconnues et renvoie le nombre réellement ajouté. */
|
||||||
|
suspend fun add(candidates: List<MediaEntry>): Int = mutex.withLock {
|
||||||
|
val known = _entries.value.mapTo(HashSet()) { it.id }
|
||||||
|
val fresh = candidates.filterNot { it.id in known }
|
||||||
|
if (fresh.isEmpty()) return@withLock 0
|
||||||
|
val merged = fresh + _entries.value
|
||||||
|
_entries.value = merged
|
||||||
|
write(merged)
|
||||||
|
fresh.size
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun remove(id: String) = mutex.withLock {
|
||||||
|
val merged = _entries.value.filterNot { it.id == id }
|
||||||
|
if (merged.size != _entries.value.size) {
|
||||||
|
_entries.value = merged
|
||||||
|
write(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun read(): List<MediaEntry> = withContext(Dispatchers.IO) {
|
||||||
|
if (!file.exists()) return@withContext emptyList()
|
||||||
|
runCatching { json.decodeFromString<List<MediaEntry>>(file.readText()) }
|
||||||
|
.getOrElse { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(entries: List<MediaEntry>) = withContext(Dispatchers.IO) {
|
||||||
|
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||||
|
tmp.writeText(json.encodeToString(entries))
|
||||||
|
tmp.renameTo(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package fr.tuxgyver.cachette.library
|
||||||
|
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Vue unifiée de la bibliothèque : contenu de l'appareil + contenu importé. */
|
||||||
|
class LibraryRepository(
|
||||||
|
private val scanner: LocalMediaScanner,
|
||||||
|
private val imported: ImportedStore,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val local = MutableStateFlow<List<MediaEntry>>(emptyList())
|
||||||
|
|
||||||
|
private val _scanning = MutableStateFlow(false)
|
||||||
|
val scanning: StateFlow<Boolean> = _scanning.asStateFlow()
|
||||||
|
|
||||||
|
val entries: StateFlow<List<MediaEntry>> =
|
||||||
|
combine(local, imported.entries) { device, remote ->
|
||||||
|
(remote + device).sortedByDescending { it.addedAt }
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
fun refreshLocal() {
|
||||||
|
scope.launch {
|
||||||
|
_scanning.value = true
|
||||||
|
try {
|
||||||
|
local.value = scanner.scan()
|
||||||
|
} finally {
|
||||||
|
_scanning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasLocalPermission(): Boolean = scanner.hasPermission()
|
||||||
|
|
||||||
|
fun entry(id: String): MediaEntry? = entries.value.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
suspend fun forget(id: String) = imported.remove(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package fr.tuxgyver.cachette.library
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit la bibliothèque de l'appareil via MediaStore.
|
||||||
|
*
|
||||||
|
* MediaStore est déjà l'index du contenu local : on ne le recopie pas dans une base à nous,
|
||||||
|
* on le relit à la demande.
|
||||||
|
*/
|
||||||
|
class LocalMediaScanner(private val context: Context) {
|
||||||
|
|
||||||
|
suspend fun scan(): List<MediaEntry> = withContext(Dispatchers.IO) {
|
||||||
|
if (!hasPermission()) return@withContext emptyList()
|
||||||
|
buildList {
|
||||||
|
addAll(scanAudio())
|
||||||
|
addAll(scanVideo())
|
||||||
|
}.sortedByDescending { it.addedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasPermission(): Boolean {
|
||||||
|
val required = requiredPermissions()
|
||||||
|
return required.any {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanAudio(): List<MediaEntry> {
|
||||||
|
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
} else {
|
||||||
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.Audio.Media._ID,
|
||||||
|
MediaStore.Audio.Media.TITLE,
|
||||||
|
MediaStore.Audio.Media.ARTIST,
|
||||||
|
MediaStore.Audio.Media.DURATION,
|
||||||
|
MediaStore.Audio.Media.ALBUM_ID,
|
||||||
|
MediaStore.Audio.Media.DATE_ADDED,
|
||||||
|
)
|
||||||
|
val entries = mutableListOf<MediaEntry>()
|
||||||
|
context.contentResolver.query(
|
||||||
|
collection,
|
||||||
|
projection,
|
||||||
|
"${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||||
|
val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
|
||||||
|
val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
|
||||||
|
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||||
|
val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
|
||||||
|
val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = cursor.getLong(idCol)
|
||||||
|
val artist = cursor.getString(artistCol).orEmpty()
|
||||||
|
entries += MediaEntry(
|
||||||
|
id = "local:audio:$id",
|
||||||
|
source = SourceKind.LOCAL,
|
||||||
|
kind = MediaKind.AUDIO,
|
||||||
|
title = cursor.getString(titleCol) ?: "Sans titre",
|
||||||
|
subtitle = if (artist.isBlank() || artist == "<unknown>") "Artiste inconnu" else artist,
|
||||||
|
uri = ContentUris.withAppendedId(collection, id).toString(),
|
||||||
|
artworkUri = albumArtUri(cursor.getLong(albumCol)).toString(),
|
||||||
|
durationMs = cursor.getLong(durationCol),
|
||||||
|
addedAt = cursor.getLong(addedCol) * 1000L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanVideo(): List<MediaEntry> {
|
||||||
|
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
} else {
|
||||||
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.Video.Media._ID,
|
||||||
|
MediaStore.Video.Media.TITLE,
|
||||||
|
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
||||||
|
MediaStore.Video.Media.DURATION,
|
||||||
|
MediaStore.Video.Media.DATE_ADDED,
|
||||||
|
)
|
||||||
|
val entries = mutableListOf<MediaEntry>()
|
||||||
|
context.contentResolver.query(collection, projection, null, null, null)?.use { cursor ->
|
||||||
|
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
|
||||||
|
val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.TITLE)
|
||||||
|
val bucketCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME)
|
||||||
|
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
|
||||||
|
val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = cursor.getLong(idCol)
|
||||||
|
val uri = ContentUris.withAppendedId(collection, id)
|
||||||
|
entries += MediaEntry(
|
||||||
|
id = "local:video:$id",
|
||||||
|
source = SourceKind.LOCAL,
|
||||||
|
kind = MediaKind.VIDEO,
|
||||||
|
title = cursor.getString(titleCol) ?: "Sans titre",
|
||||||
|
subtitle = cursor.getString(bucketCol) ?: "Vidéos",
|
||||||
|
uri = uri.toString(),
|
||||||
|
artworkUri = uri.toString(),
|
||||||
|
durationMs = cursor.getLong(durationCol),
|
||||||
|
addedAt = cursor.getLong(addedCol) * 1000L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun albumArtUri(albumId: Long): Uri =
|
||||||
|
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun requiredPermissions(): Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
arrayOf(
|
||||||
|
android.Manifest.permission.READ_MEDIA_AUDIO,
|
||||||
|
android.Manifest.permission.READ_MEDIA_VIDEO,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package fr.tuxgyver.cachette.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
enum class MediaKind { AUDIO, VIDEO }
|
||||||
|
|
||||||
|
/** Origine d'un média. Détermine ce qui est déjà hors connexion et ce qui doit être mis en cache. */
|
||||||
|
enum class SourceKind(val label: String) {
|
||||||
|
LOCAL("Appareil"),
|
||||||
|
URL("Lien direct"),
|
||||||
|
PODCAST("Podcast"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une entrée de la bibliothèque, quelle que soit sa provenance.
|
||||||
|
*
|
||||||
|
* [id] est stable et sert aussi d'identifiant de téléchargement Media3 : c'est ce qui permet
|
||||||
|
* de relier une entrée à son état de cache sans dupliquer l'information.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MediaEntry(
|
||||||
|
val id: String,
|
||||||
|
val source: SourceKind,
|
||||||
|
val kind: MediaKind,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val uri: String,
|
||||||
|
val artworkUri: String? = null,
|
||||||
|
val durationMs: Long = 0L,
|
||||||
|
val addedAt: Long = 0L,
|
||||||
|
) {
|
||||||
|
/** Un média local est déjà disponible hors connexion : il n'a rien à télécharger. */
|
||||||
|
val isRemote: Boolean get() = source != SourceKind.LOCAL
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package fr.tuxgyver.cachette.offline
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.offline.Download
|
||||||
|
import androidx.media3.exoplayer.offline.DownloadManager
|
||||||
|
import androidx.media3.exoplayer.offline.DownloadService
|
||||||
|
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||||
|
import androidx.media3.exoplayer.workmanager.WorkManagerScheduler
|
||||||
|
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||||
|
import fr.tuxgyver.cachette.CachetteApp
|
||||||
|
import fr.tuxgyver.cachette.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de mise en cache. Il tourne en premier plan pendant les téléchargements et délègue la
|
||||||
|
* reprise après coupure réseau ou redémarrage à WorkManager.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class CachetteDownloadService : DownloadService(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
||||||
|
CHANNEL_ID,
|
||||||
|
R.string.download_channel_name,
|
||||||
|
0,
|
||||||
|
) {
|
||||||
|
private val notificationHelper: DownloadNotificationHelper by lazy {
|
||||||
|
DownloadNotificationHelper(this, CHANNEL_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDownloadManager(): DownloadManager =
|
||||||
|
(application as CachetteApp).container.offline.downloadManager
|
||||||
|
|
||||||
|
override fun getScheduler(): Scheduler = WorkManagerScheduler(this, WORK_NAME)
|
||||||
|
|
||||||
|
override fun getForegroundNotification(
|
||||||
|
downloads: MutableList<Download>,
|
||||||
|
notMetRequirements: Int,
|
||||||
|
): Notification = notificationHelper.buildProgressNotification(
|
||||||
|
this,
|
||||||
|
R.drawable.ic_offline,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
downloads,
|
||||||
|
notMetRequirements,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NOTIFICATION_ID = 4201
|
||||||
|
const val CHANNEL_ID = "cachette_downloads"
|
||||||
|
const val WORK_NAME = "cachette-downloads"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package fr.tuxgyver.cachette.offline
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.offline.Download
|
||||||
|
import androidx.media3.exoplayer.offline.DownloadManager
|
||||||
|
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||||
|
import androidx.media3.exoplayer.offline.DownloadService
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** État hors connexion d'une entrée, tel que l'UI en a besoin. */
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
data class OfflineState(
|
||||||
|
val id: String,
|
||||||
|
val state: Int,
|
||||||
|
val percent: Float,
|
||||||
|
val bytesDownloaded: Long,
|
||||||
|
) {
|
||||||
|
val isComplete: Boolean get() = state == Download.STATE_COMPLETED
|
||||||
|
val isFailed: Boolean get() = state == Download.STATE_FAILED
|
||||||
|
val isPending: Boolean
|
||||||
|
get() = state == Download.STATE_QUEUED || state == Download.STATE_RESTARTING
|
||||||
|
val isRunning: Boolean get() = state == Download.STATE_DOWNLOADING
|
||||||
|
val isActive: Boolean get() = isPending || isRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose l'état du cache hors connexion.
|
||||||
|
*
|
||||||
|
* L'index Media3 est la source de vérité ; on ne duplique rien. La progression des
|
||||||
|
* téléchargements en cours n'étant pas notifiée, elle est relue par tic d'une seconde,
|
||||||
|
* et seulement tant qu'un écran l'observe.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class DownloadTracker(
|
||||||
|
private val context: Context,
|
||||||
|
private val downloadManager: DownloadManager,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val indexed = MutableStateFlow<Map<String, OfflineState>>(emptyMap())
|
||||||
|
|
||||||
|
private val ticker = flow {
|
||||||
|
while (true) {
|
||||||
|
emit(Unit)
|
||||||
|
delay(1_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val states: StateFlow<Map<String, OfflineState>> =
|
||||||
|
indexed.combine(ticker) { snapshot, _ ->
|
||||||
|
snapshot + downloadManager.currentDownloads.associate { it.request.id to it.toOfflineState() }
|
||||||
|
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
|
private val listener = object : DownloadManager.Listener {
|
||||||
|
override fun onDownloadChanged(manager: DownloadManager, download: Download, finalException: Exception?) {
|
||||||
|
scope.launch { reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||||
|
scope.launch { reload() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
downloadManager.addListener(listener)
|
||||||
|
scope.launch { reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(entry: MediaEntry) {
|
||||||
|
if (!entry.isRemote) return
|
||||||
|
val request = DownloadRequest.Builder(entry.id, entry.uri.toUri())
|
||||||
|
.setData(entry.title.toByteArray())
|
||||||
|
.build()
|
||||||
|
DownloadService.sendAddDownload(context, CachetteDownloadService::class.java, request, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(id: String) {
|
||||||
|
DownloadService.sendRemoveDownload(context, CachetteDownloadService::class.java, id, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAll() {
|
||||||
|
DownloadService.sendRemoveAllDownloads(context, CachetteDownloadService::class.java, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reload() = withContext(Dispatchers.IO) {
|
||||||
|
val snapshot = mutableMapOf<String, OfflineState>()
|
||||||
|
runCatching {
|
||||||
|
downloadManager.downloadIndex.getDownloads().use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val download = cursor.download
|
||||||
|
snapshot[download.request.id] = download.toOfflineState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
indexed.value = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Download.toOfflineState() = OfflineState(
|
||||||
|
id = request.id,
|
||||||
|
state = state,
|
||||||
|
percent = if (percentDownloaded.isNaN()) 0f else percentDownloaded,
|
||||||
|
bytesDownloaded = bytesDownloaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package fr.tuxgyver.cachette.offline
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.database.DatabaseProvider
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
|
import androidx.media3.datasource.cache.Cache
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
|
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
|
import androidx.media3.exoplayer.offline.DownloadManager
|
||||||
|
import androidx.media3.exoplayer.scheduler.Requirements
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le cache hors connexion et le gestionnaire de téléchargement, partagés par la lecture et l'UI.
|
||||||
|
*
|
||||||
|
* Le cache utilise [NoOpCacheEvictor] : il contient exactement ce que l'utilisateur a demandé à
|
||||||
|
* garder, rien de plus, et rien n'est supprimé dans son dos. La lecture le lit sans y écrire
|
||||||
|
* (`setCacheWriteDataSinkFactory(null)`), pour qu'un simple écoute en ligne ne le fasse pas gonfler.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class OfflineModule(private val context: Context) {
|
||||||
|
|
||||||
|
private val databaseProvider: DatabaseProvider by lazy { StandaloneDatabaseProvider(context) }
|
||||||
|
|
||||||
|
private val httpDataSourceFactory: DataSource.Factory by lazy {
|
||||||
|
DefaultHttpDataSource.Factory()
|
||||||
|
.setUserAgent("Cachette/1.0 (Android)")
|
||||||
|
.setAllowCrossProtocolRedirects(true)
|
||||||
|
.setConnectTimeoutMs(20_000)
|
||||||
|
.setReadTimeoutMs(30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cache: Cache by lazy {
|
||||||
|
val root = File(context.getExternalFilesDir(null) ?: context.filesDir, "offline-media")
|
||||||
|
SimpleCache(root, NoOpCacheEvictor(), databaseProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadManager: DownloadManager by lazy {
|
||||||
|
DownloadManager(
|
||||||
|
context,
|
||||||
|
databaseProvider,
|
||||||
|
cache,
|
||||||
|
httpDataSourceFactory,
|
||||||
|
Executors.newFixedThreadPool(MAX_PARALLEL_DOWNLOADS),
|
||||||
|
).apply {
|
||||||
|
maxParallelDownloads = MAX_PARALLEL_DOWNLOADS
|
||||||
|
requirements = Requirements(Requirements.NETWORK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source de lecture : cache d'abord, réseau ou fichier local ensuite, jamais d'écriture. */
|
||||||
|
fun playbackDataSourceFactory(): DataSource.Factory =
|
||||||
|
CacheDataSource.Factory()
|
||||||
|
.setCache(cache)
|
||||||
|
.setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, httpDataSourceFactory))
|
||||||
|
.setCacheWriteDataSinkFactory(null)
|
||||||
|
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||||
|
|
||||||
|
/** Octets réellement occupés par le contenu mis de côté. */
|
||||||
|
fun cacheSizeBytes(): Long = cache.cacheSpace
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_PARALLEL_DOWNLOADS = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package fr.tuxgyver.cachette.playback
|
||||||
|
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les `MediaItem` transmis par un contrôleur perdent leur URI en chemin : c'est le service qui la
|
||||||
|
* rétablit à partir de l'identifiant. Ce mappage est donc le seul endroit où une entrée devient
|
||||||
|
* lisible, des deux côtés de la session.
|
||||||
|
*/
|
||||||
|
fun MediaEntry.toMediaItem(): MediaItem = MediaItem.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setUri(uri)
|
||||||
|
.setMediaMetadata(
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setArtist(subtitle)
|
||||||
|
.setArtworkUri(artworkUri?.toUri())
|
||||||
|
.setIsBrowsable(false)
|
||||||
|
.setIsPlayable(true)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package fr.tuxgyver.cachette.playback
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import com.google.common.util.concurrent.Futures
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
import fr.tuxgyver.cachette.CachetteApp
|
||||||
|
import fr.tuxgyver.cachette.MainActivity
|
||||||
|
|
||||||
|
/** Lecture audio et vidéo en arrière-plan, avec notification et commandes système. */
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class PlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
|
private var session: MediaSession? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
val container = (application as CachetteApp).container
|
||||||
|
|
||||||
|
val player = ExoPlayer.Builder(this)
|
||||||
|
.setMediaSourceFactory(DefaultMediaSourceFactory(container.offline.playbackDataSourceFactory()))
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.build(),
|
||||||
|
/* handleAudioFocus = */ true,
|
||||||
|
)
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val openApp = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
session = MediaSession.Builder(this, player)
|
||||||
|
.setSessionActivity(openApp)
|
||||||
|
.setCallback(ResolvingCallback())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
val player = session?.player
|
||||||
|
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
session?.run {
|
||||||
|
player.release()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
session = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rend leur URI aux éléments reçus du contrôleur, à partir de la bibliothèque. */
|
||||||
|
private inner class ResolvingCallback : MediaSession.Callback {
|
||||||
|
override fun onAddMediaItems(
|
||||||
|
mediaSession: MediaSession,
|
||||||
|
controller: MediaSession.ControllerInfo,
|
||||||
|
mediaItems: MutableList<MediaItem>,
|
||||||
|
): ListenableFuture<MutableList<MediaItem>> {
|
||||||
|
val library = (application as CachetteApp).container.library
|
||||||
|
val resolved = mediaItems.mapTo(mutableListOf()) { item ->
|
||||||
|
library.entry(item.mediaId)?.toMediaItem() ?: item
|
||||||
|
}
|
||||||
|
return Futures.immediateFuture(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package fr.tuxgyver.cachette.playback
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
|
import androidx.media3.session.SessionToken
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
data class PlayerUiState(
|
||||||
|
val mediaId: String? = null,
|
||||||
|
val title: String = "",
|
||||||
|
val subtitle: String = "",
|
||||||
|
val artworkUri: String? = null,
|
||||||
|
val kind: MediaKind = MediaKind.AUDIO,
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
val isBuffering: Boolean = false,
|
||||||
|
val durationMs: Long = 0L,
|
||||||
|
val hasNext: Boolean = false,
|
||||||
|
val hasPrevious: Boolean = false,
|
||||||
|
) {
|
||||||
|
val isIdle: Boolean get() = mediaId == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pont entre l'interface et la session de lecture.
|
||||||
|
*
|
||||||
|
* La position n'est pas notifiée par le lecteur : elle est relue par tic, et seulement tant qu'un
|
||||||
|
* écran l'observe.
|
||||||
|
*/
|
||||||
|
class PlayerController(
|
||||||
|
private val context: Context,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val resolve: (String) -> MediaEntry?,
|
||||||
|
) {
|
||||||
|
private var controller: MediaController? = null
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(PlayerUiState())
|
||||||
|
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
val positionMs: StateFlow<Long> = flow {
|
||||||
|
while (true) {
|
||||||
|
emit(controller?.currentPosition ?: 0L)
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}.stateIn(scope, SharingStarted.WhileSubscribed(2_000), 0L)
|
||||||
|
|
||||||
|
/** Le lecteur brut, nécessaire pour rattacher une surface vidéo. */
|
||||||
|
val player: Player? get() = controller
|
||||||
|
|
||||||
|
private val listener = object : Player.Listener {
|
||||||
|
override fun onEvents(player: Player, events: Player.Events) = syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
if (controller != null) return
|
||||||
|
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||||
|
val future = MediaController.Builder(context, token).buildAsync()
|
||||||
|
future.addListener({
|
||||||
|
controller = runCatching { future.get() }.getOrNull()?.also {
|
||||||
|
it.addListener(listener)
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
}, ContextCompat.getMainExecutor(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
controller?.removeListener(listener)
|
||||||
|
controller?.release()
|
||||||
|
controller = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lance une file de lecture à partir de l'entrée choisie. */
|
||||||
|
fun play(queue: List<MediaEntry>, startIndex: Int) {
|
||||||
|
val controller = controller ?: return
|
||||||
|
if (queue.isEmpty()) return
|
||||||
|
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
|
||||||
|
controller.prepare()
|
||||||
|
controller.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePlayPause() {
|
||||||
|
val controller = controller ?: return
|
||||||
|
if (controller.isPlaying) controller.pause() else controller.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekTo(positionMs: Long) {
|
||||||
|
controller?.seekTo(positionMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipBy(deltaMs: Long) {
|
||||||
|
val controller = controller ?: return
|
||||||
|
controller.seekTo((controller.currentPosition + deltaMs).coerceAtLeast(0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun next() = controller?.seekToNextMediaItem() ?: Unit
|
||||||
|
|
||||||
|
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
controller?.stop()
|
||||||
|
controller?.clearMediaItems()
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncState() {
|
||||||
|
val controller = controller
|
||||||
|
if (controller == null || controller.currentMediaItem == null) {
|
||||||
|
_state.value = PlayerUiState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val item = controller.currentMediaItem!!
|
||||||
|
val entry = resolve(item.mediaId)
|
||||||
|
_state.value = PlayerUiState(
|
||||||
|
mediaId = item.mediaId,
|
||||||
|
title = entry?.title ?: item.mediaMetadata.title?.toString().orEmpty(),
|
||||||
|
subtitle = entry?.subtitle ?: item.mediaMetadata.artist?.toString().orEmpty(),
|
||||||
|
artworkUri = entry?.artworkUri ?: item.mediaMetadata.artworkUri?.toString(),
|
||||||
|
kind = entry?.kind ?: MediaKind.AUDIO,
|
||||||
|
isPlaying = controller.isPlaying,
|
||||||
|
isBuffering = controller.playbackState == Player.STATE_BUFFERING,
|
||||||
|
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
|
||||||
|
hasNext = controller.hasNextMediaItem(),
|
||||||
|
hasPrevious = controller.hasPreviousMediaItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package fr.tuxgyver.cachette.sources
|
||||||
|
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche dans archive.org.
|
||||||
|
*
|
||||||
|
* La recherche ne renvoie que des identifiants ; le fichier réellement lisible n'est déterminé
|
||||||
|
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
|
||||||
|
* trente éléments dont vingt-neuf seront ignorés.
|
||||||
|
*/
|
||||||
|
class ArchiveOrgCatalog : Catalog {
|
||||||
|
|
||||||
|
override val id = CatalogId.ARCHIVE
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val q = URLEncoder.encode("($query) AND mediatype:(audio OR movies)", "UTF-8")
|
||||||
|
val url = "https://archive.org/advancedsearch.php?q=$q" +
|
||||||
|
"&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype" +
|
||||||
|
"&rows=30&page=1&output=json"
|
||||||
|
|
||||||
|
val body = fetch(url)
|
||||||
|
val docs = json.parseToJsonElement(body).jsonObject["response"]
|
||||||
|
?.jsonObject?.get("docs") as? JsonArray
|
||||||
|
?: throw IllegalStateException("Réponse inattendue d'archive.org")
|
||||||
|
|
||||||
|
docs.mapNotNull { element ->
|
||||||
|
val doc = element as? JsonObject ?: return@mapNotNull null
|
||||||
|
val identifier = doc.text("identifier") ?: return@mapNotNull null
|
||||||
|
CatalogItem(
|
||||||
|
id = identifier,
|
||||||
|
title = doc.text("title") ?: identifier,
|
||||||
|
subtitle = doc.text("creator") ?: "Internet Archive",
|
||||||
|
artworkUri = "https://archive.org/services/img/$identifier",
|
||||||
|
hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
|
||||||
|
val files = metadata["files"] as? JsonArray ?: JsonArray(emptyList())
|
||||||
|
val wantsVideo = item.hint == "Vidéo"
|
||||||
|
|
||||||
|
val playable = files.mapNotNull { it as? JsonObject }
|
||||||
|
.firstOrNull { file ->
|
||||||
|
val name = file.text("name").orEmpty().lowercase()
|
||||||
|
if (wantsVideo) name.endsWith(".mp4") else name.endsWith(".mp3")
|
||||||
|
}
|
||||||
|
?: throw IllegalStateException("Aucun fichier lisible pour « ${item.title} »")
|
||||||
|
|
||||||
|
val name = playable.text("name")!!
|
||||||
|
val seconds = playable.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
MediaEntry(
|
||||||
|
id = stableId("ia:", "${item.id}/$name"),
|
||||||
|
source = SourceKind.URL,
|
||||||
|
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||||
|
title = item.title,
|
||||||
|
subtitle = item.subtitle,
|
||||||
|
uri = "https://archive.org/download/${item.id}/${URLEncoder.encode(name, "UTF-8").replace("+", "%20")}",
|
||||||
|
artworkUri = item.artworkUri,
|
||||||
|
durationMs = seconds * 1000,
|
||||||
|
addedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(url: String): String {
|
||||||
|
val connection = openConnection(url)
|
||||||
|
return try {
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("archive.org a répondu ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
connection.inputStream.bufferedReader().readText()
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** archive.org renvoie tantôt une chaîne, tantôt un tableau : on prend la première valeur utile. */
|
||||||
|
internal fun JsonObject.text(key: String): String? = when (val value = this[key]) {
|
||||||
|
is JsonPrimitive -> value.content.takeIf { it.isNotBlank() }
|
||||||
|
is JsonArray -> value.firstOrNull()?.let { (it as? JsonPrimitive)?.content }?.takeIf { it.isNotBlank() }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package fr.tuxgyver.cachette.sources
|
||||||
|
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
|
||||||
|
/** Un résultat de recherche dans un catalogue, avant qu'on sache quel fichier exact il désigne. */
|
||||||
|
data class CatalogItem(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val artworkUri: String? = null,
|
||||||
|
val hint: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Catalogue public interrogeable sans compte ni clé d'API. */
|
||||||
|
interface Catalog {
|
||||||
|
val id: CatalogId
|
||||||
|
|
||||||
|
suspend fun search(query: String): Result<List<CatalogItem>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforme un résultat en entrées jouables. Un livre audio en rend plusieurs, un morceau une
|
||||||
|
* seule : la résolution est faite ici, à la sélection, et pas pendant la recherche.
|
||||||
|
*/
|
||||||
|
suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>>
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CatalogId(
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val license: String,
|
||||||
|
val suggestion: String,
|
||||||
|
) {
|
||||||
|
ARCHIVE(
|
||||||
|
title = "Internet Archive",
|
||||||
|
description = "Musique, concerts et films versés au domaine public ou sous licence libre.",
|
||||||
|
license = "Domaine public / Creative Commons",
|
||||||
|
suggestion = "Grateful Dead",
|
||||||
|
),
|
||||||
|
LIBRIVOX(
|
||||||
|
title = "LibriVox",
|
||||||
|
description = "Livres audio lus par des bénévoles, tous dans le domaine public.",
|
||||||
|
license = "Domaine public",
|
||||||
|
suggestion = "Jules Verne",
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package fr.tuxgyver.cachette.sources
|
||||||
|
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
internal fun stableId(prefix: String, value: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1").digest(value.toByteArray())
|
||||||
|
return prefix + digest.joinToString("") { "%02x".format(it) }.take(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun openConnection(url: String, method: String = "GET"): HttpURLConnection =
|
||||||
|
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = method
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 20_000
|
||||||
|
setRequestProperty("User-Agent", "Cachette/1.0 (Android)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package fr.tuxgyver.cachette.sources
|
||||||
|
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche dans LibriVox.
|
||||||
|
*
|
||||||
|
* Chaque livre publie un flux RSS : la résolution réutilise donc l'import de podcast plutôt que
|
||||||
|
* de redécrire un parcours de chapitres.
|
||||||
|
*/
|
||||||
|
class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
||||||
|
|
||||||
|
override val id = CatalogId.LIBRIVOX
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25" +
|
||||||
|
"&title=${URLEncoder.encode(query, "UTF-8")}"
|
||||||
|
|
||||||
|
val connection = openConnection(url)
|
||||||
|
val body = try {
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
connection.inputStream.bufferedReader().readText()
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray
|
||||||
|
?: throw IllegalStateException("Aucun livre trouvé")
|
||||||
|
|
||||||
|
books.mapNotNull { element ->
|
||||||
|
val book = element as? JsonObject ?: return@mapNotNull null
|
||||||
|
val feed = book.text("url_rss") ?: return@mapNotNull null
|
||||||
|
CatalogItem(
|
||||||
|
id = feed,
|
||||||
|
title = book.text("title") ?: "Livre audio",
|
||||||
|
subtitle = book.authors(),
|
||||||
|
hint = "Livre audio",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
|
||||||
|
rss.import(item.id).map { it.episodes }
|
||||||
|
|
||||||
|
private fun JsonObject.authors(): String {
|
||||||
|
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
|
||||||
|
return authors.mapNotNull { it as? JsonObject }
|
||||||
|
.joinToString(", ") { author ->
|
||||||
|
listOfNotNull(author.text("first_name"), author.text("last_name")).joinToString(" ")
|
||||||
|
}
|
||||||
|
.ifBlank { "LibriVox" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package fr.tuxgyver.cachette.sources
|
||||||
|
|
||||||
|
import android.util.Xml
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
data class PodcastFeed(
|
||||||
|
val title: String,
|
||||||
|
val artworkUri: String?,
|
||||||
|
val episodes: List<MediaEntry>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Importe un flux RSS de podcast : chaque `<enclosure>` devient une entrée téléchargeable. */
|
||||||
|
class RssImporter {
|
||||||
|
|
||||||
|
suspend fun import(feedUrl: String): Result<PodcastFeed> = withContext(Dispatchers.IO) {
|
||||||
|
val url = feedUrl.trim()
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://"))
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val connection = openConnection(url)
|
||||||
|
try {
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
parse(connection.inputStream.bufferedReader().readText())
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(xml: String): PodcastFeed {
|
||||||
|
val parser = Xml.newPullParser().apply {
|
||||||
|
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||||
|
setInput(xml.reader())
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedTitle = ""
|
||||||
|
var feedImage: String? = null
|
||||||
|
val episodes = mutableListOf<MediaEntry>()
|
||||||
|
|
||||||
|
var item: Draft? = null
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (event) {
|
||||||
|
XmlPullParser.START_TAG -> when (parser.name) {
|
||||||
|
"item", "entry" -> item = Draft()
|
||||||
|
"title" -> {
|
||||||
|
val value = parser.textOrEmpty()
|
||||||
|
if (item != null) item.title = value else if (feedTitle.isEmpty()) feedTitle = value
|
||||||
|
}
|
||||||
|
"enclosure" -> if (item != null) {
|
||||||
|
item.url = parser.getAttributeValue(null, "url")
|
||||||
|
item.type = parser.getAttributeValue(null, "type").orEmpty()
|
||||||
|
}
|
||||||
|
"itunes:image" -> {
|
||||||
|
val href = parser.getAttributeValue(null, "href")
|
||||||
|
if (item != null) item.image = href else if (feedImage == null) feedImage = href
|
||||||
|
}
|
||||||
|
"itunes:duration" -> if (item != null) item.duration = parser.textOrEmpty()
|
||||||
|
"pubDate" -> if (item != null) item.pubDate = parser.textOrEmpty()
|
||||||
|
"url" -> if (item == null && feedImage == null) feedImage = parser.textOrEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
XmlPullParser.END_TAG -> if (parser.name == "item" || parser.name == "entry") {
|
||||||
|
item?.toEntry(feedTitle, feedImage)?.let(episodes::add)
|
||||||
|
item = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodes.isEmpty()) {
|
||||||
|
throw IllegalStateException("Aucun épisode téléchargeable trouvé dans ce flux")
|
||||||
|
}
|
||||||
|
return PodcastFeed(
|
||||||
|
title = feedTitle.ifEmpty { "Podcast" },
|
||||||
|
artworkUri = feedImage,
|
||||||
|
episodes = episodes.take(MAX_EPISODES),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Draft {
|
||||||
|
var title: String = ""
|
||||||
|
var url: String? = null
|
||||||
|
var type: String = ""
|
||||||
|
var image: String? = null
|
||||||
|
var duration: String = ""
|
||||||
|
var pubDate: String = ""
|
||||||
|
|
||||||
|
fun toEntry(feedTitle: String, feedImage: String?): MediaEntry? {
|
||||||
|
val mediaUrl = url?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return MediaEntry(
|
||||||
|
id = stableId("pod:", mediaUrl),
|
||||||
|
source = SourceKind.PODCAST,
|
||||||
|
kind = if (type.startsWith("video/")) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||||
|
title = title.ifBlank { "Épisode" },
|
||||||
|
subtitle = feedTitle.ifBlank { "Podcast" },
|
||||||
|
uri = mediaUrl,
|
||||||
|
artworkUri = image ?: feedImage,
|
||||||
|
durationMs = parseDuration(duration),
|
||||||
|
addedAt = parseDate(pubDate),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_EPISODES = 100
|
||||||
|
|
||||||
|
fun XmlPullParser.textOrEmpty(): String =
|
||||||
|
runCatching { nextText().trim() }.getOrDefault("")
|
||||||
|
|
||||||
|
/** `1:02:03`, `12:34` ou un nombre de secondes. */
|
||||||
|
fun parseDuration(raw: String): Long {
|
||||||
|
if (raw.isBlank()) return 0L
|
||||||
|
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
|
||||||
|
return when (parts.size) {
|
||||||
|
1 -> parts[0] * 1000
|
||||||
|
2 -> (parts[0] * 60 + parts[1]) * 1000
|
||||||
|
3 -> (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseDate(raw: String): Long {
|
||||||
|
if (raw.isBlank()) return System.currentTimeMillis()
|
||||||
|
val formats = listOf("EEE, dd MMM yyyy HH:mm:ss Z", "EEE, dd MMM yyyy HH:mm:ss zzz")
|
||||||
|
for (pattern in formats) {
|
||||||
|
runCatching { SimpleDateFormat(pattern, Locale.US).parse(raw)?.time }
|
||||||
|
.getOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
return System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package fr.tuxgyver.cachette.sources
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Importe un lien direct vers un fichier média (mp3, m4a, mp4, webm…). */
|
||||||
|
class UrlImporter {
|
||||||
|
|
||||||
|
suspend fun import(rawUrl: String): Result<MediaEntry> = withContext(Dispatchers.IO) {
|
||||||
|
val url = rawUrl.trim()
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://"))
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val connection = openConnection(url, "HEAD")
|
||||||
|
val contentType = try {
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
connection.contentType.orEmpty()
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
val kind = kindOf(contentType, url)
|
||||||
|
?: throw IllegalStateException("Ce lien ne pointe pas vers un fichier audio ou vidéo")
|
||||||
|
|
||||||
|
val name = url.toUri().lastPathSegment
|
||||||
|
?.substringBeforeLast('.')
|
||||||
|
?.replace(Regex("[_-]+"), " ")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: "Média importé"
|
||||||
|
|
||||||
|
MediaEntry(
|
||||||
|
id = stableId("url:", url),
|
||||||
|
source = SourceKind.URL,
|
||||||
|
kind = kind,
|
||||||
|
title = name,
|
||||||
|
subtitle = url.toUri().host ?: "Lien direct",
|
||||||
|
uri = url,
|
||||||
|
addedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun kindOf(contentType: String, url: String): MediaKind? {
|
||||||
|
val mime = contentType.substringBefore(';').trim().lowercase()
|
||||||
|
return when {
|
||||||
|
mime.startsWith("video/") -> MediaKind.VIDEO
|
||||||
|
mime.startsWith("audio/") -> MediaKind.AUDIO
|
||||||
|
mime == "application/vnd.apple.mpegurl" || mime == "application/x-mpegurl" -> MediaKind.VIDEO
|
||||||
|
else -> when (url.substringAfterLast('.', "").lowercase().substringBefore('?')) {
|
||||||
|
"mp3", "m4a", "aac", "ogg", "opus", "flac", "wav" -> MediaKind.AUDIO
|
||||||
|
"mp4", "mkv", "webm", "m4v", "mov" -> MediaKind.VIDEO
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.CloudDownload
|
||||||
|
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||||
|
import androidx.compose.material.icons.rounded.Tune
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import fr.tuxgyver.cachette.AppContainer
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import fr.tuxgyver.cachette.sources.CatalogId
|
||||||
|
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen
|
||||||
|
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen
|
||||||
|
import fr.tuxgyver.cachette.ui.library.LibraryScreen
|
||||||
|
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
|
||||||
|
import fr.tuxgyver.cachette.ui.player.MiniPlayer
|
||||||
|
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
|
||||||
|
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
||||||
|
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
|
||||||
|
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
|
||||||
|
SOURCES("sources", "Sources", Icons.Rounded.Tune),
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BROWSE_ROUTE = "browse/{source}"
|
||||||
|
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CachetteRoot(container: AppContainer) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = backStackEntry?.destination?.route
|
||||||
|
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val playerState by container.player.state.collectAsStateWithLifecycle()
|
||||||
|
val position by container.player.positionMs.collectAsStateWithLifecycle()
|
||||||
|
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val snackbarHost = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val play: (List<MediaEntry>, Int) -> Unit = { queue, index ->
|
||||||
|
container.player.play(queue, index)
|
||||||
|
expanded = true
|
||||||
|
}
|
||||||
|
val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHost) },
|
||||||
|
bottomBar = {
|
||||||
|
Column {
|
||||||
|
if (!playerState.isIdle) {
|
||||||
|
MiniPlayer(
|
||||||
|
state = playerState,
|
||||||
|
positionMs = position,
|
||||||
|
onExpand = { expanded = true },
|
||||||
|
onTogglePlay = container.player::togglePlayPause,
|
||||||
|
onNext = container.player::next,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
NavigationBar {
|
||||||
|
Tab.entries.forEach { tab ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentRoute == tab.route,
|
||||||
|
onClick = { navController.switchTab(tab.route) },
|
||||||
|
icon = { Icon(tab.icon, contentDescription = null) },
|
||||||
|
label = { Text(tab.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
NavHost(navController = navController, startDestination = Tab.LIBRARY.route) {
|
||||||
|
composable(Tab.LIBRARY.route) {
|
||||||
|
LibraryScreen(
|
||||||
|
container = container,
|
||||||
|
currentMediaId = playerState.mediaId,
|
||||||
|
onPlay = play,
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Tab.OFFLINE.route) {
|
||||||
|
DownloadsScreen(
|
||||||
|
container = container,
|
||||||
|
currentMediaId = playerState.mediaId,
|
||||||
|
onPlay = play,
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Tab.SOURCES.route) {
|
||||||
|
SourcesScreen(
|
||||||
|
container = container,
|
||||||
|
contentPadding = padding,
|
||||||
|
onMessage = notify,
|
||||||
|
onBrowseSource = { navController.navigate("browse/${it.name}") },
|
||||||
|
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BROWSE_ROUTE) { entry ->
|
||||||
|
val source = SourceKind.valueOf(entry.arguments?.getString("source") ?: SourceKind.LOCAL.name)
|
||||||
|
SourceBrowseScreen(
|
||||||
|
container = container,
|
||||||
|
source = source,
|
||||||
|
currentMediaId = playerState.mediaId,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
|
onPlay = play,
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(CATALOG_ROUTE) { entry ->
|
||||||
|
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
|
||||||
|
CatalogBrowseScreen(
|
||||||
|
container = container,
|
||||||
|
catalogId = catalog,
|
||||||
|
onBack = navController::popBackStack,
|
||||||
|
onMessage = notify,
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded && !playerState.isIdle,
|
||||||
|
enter = slideInVertically { it },
|
||||||
|
exit = slideOutVertically { it },
|
||||||
|
) {
|
||||||
|
val entry = playerState.mediaId?.let(container.library::entry)
|
||||||
|
NowPlayingScreen(
|
||||||
|
state = playerState,
|
||||||
|
positionMs = position,
|
||||||
|
entry = entry,
|
||||||
|
offline = playerState.mediaId?.let { offlineStates[it] },
|
||||||
|
player = container.player.player,
|
||||||
|
onCollapse = { expanded = false },
|
||||||
|
onTogglePlay = container.player::togglePlayPause,
|
||||||
|
onSeek = container.player::seekTo,
|
||||||
|
onSkipBy = container.player::skipBy,
|
||||||
|
onNext = container.player::next,
|
||||||
|
onPrevious = container.player::previous,
|
||||||
|
onDownload = { entry?.let(container.downloads::download) },
|
||||||
|
onRemoveOffline = { playerState.mediaId?.let(container.downloads::remove) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = expanded) { expanded = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un onglet se rejoint sans empiler : on revient là où on l'avait laissé. */
|
||||||
|
private fun NavHostController.switchTab(route: String) {
|
||||||
|
navigate(route) {
|
||||||
|
popUpTo(graph.startDestinationId) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.browse
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import fr.tuxgyver.cachette.AppContainer
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.sources.CatalogId
|
||||||
|
import fr.tuxgyver.cachette.sources.CatalogItem
|
||||||
|
import fr.tuxgyver.cachette.ui.components.Artwork
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche dans un catalogue libre de droits, puis ajout de la sélection à la bibliothèque.
|
||||||
|
*
|
||||||
|
* L'ajout déclenche aussi la mise en cache : on ne parcourt pas un catalogue pour regarder
|
||||||
|
* passer les titres, mais pour les emporter.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CatalogBrowseScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
catalogId: CatalogId,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val catalog = container.catalogs.getValue(catalogId)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
|
||||||
|
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
|
||||||
|
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||||
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
var adding by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun runSearch() {
|
||||||
|
if (query.isBlank()) return
|
||||||
|
searching = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
catalog.search(query).fold(
|
||||||
|
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
|
||||||
|
onFailure = { error = it.message ?: "Recherche impossible" },
|
||||||
|
)
|
||||||
|
searching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(catalogId) { runSearch() }
|
||||||
|
|
||||||
|
// La barre de titre occupe déjà l'espace haut du Scaffold : la liste ne reprend que le bas.
|
||||||
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(catalogId.title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = catalogId.license,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
placeholder = { Text("Rechercher dans ${catalogId.title}") },
|
||||||
|
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
searching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
error != null -> Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error!!,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> LazyColumn(
|
||||||
|
contentPadding = listPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(results, key = { it.id }) { item ->
|
||||||
|
CatalogRow(
|
||||||
|
item = item,
|
||||||
|
selected = item.id in selection,
|
||||||
|
onToggle = {
|
||||||
|
selection = if (item.id in selection) selection - item.id
|
||||||
|
else selection + item.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(88.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionBar(
|
||||||
|
count = selection.size,
|
||||||
|
actionLabel = "Ajouter et garder",
|
||||||
|
busy = adding,
|
||||||
|
onClear = { selection = emptySet() },
|
||||||
|
onConfirm = {
|
||||||
|
adding = true
|
||||||
|
scope.launch {
|
||||||
|
val chosen = results.filter { it.id in selection }
|
||||||
|
var added = 0
|
||||||
|
var failed = 0
|
||||||
|
chosen.forEach { item ->
|
||||||
|
catalog.resolve(item).fold(
|
||||||
|
onSuccess = { entries ->
|
||||||
|
container.imported.add(entries)
|
||||||
|
entries.forEach(container.downloads::download)
|
||||||
|
added += entries.size
|
||||||
|
},
|
||||||
|
onFailure = { failed++ },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
adding = false
|
||||||
|
selection = emptySet()
|
||||||
|
onMessage(
|
||||||
|
buildString {
|
||||||
|
append("$added élément(s) ajoutés et mis en cache")
|
||||||
|
if (failed > 0) append(" · $failed indisponible(s)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(contentPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CatalogRow(
|
||||||
|
item: CatalogItem,
|
||||||
|
selected: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surface,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onToggle),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Artwork(
|
||||||
|
uri = item.artworkUri,
|
||||||
|
title = item.title,
|
||||||
|
kind = if (item.hint == "Vidéo") MediaKind.VIDEO else MediaKind.AUDIO,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = listOf(item.subtitle, item.hint).filter { it.isNotBlank() }.joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Checkbox(checked = selected, onCheckedChange = { onToggle() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.browse
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Download
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** Barre d'action des écrans de sélection : combien d'éléments, et que faire de la sélection. */
|
||||||
|
@Composable
|
||||||
|
fun SelectionBar(
|
||||||
|
count: Int,
|
||||||
|
actionLabel: String,
|
||||||
|
busy: Boolean,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = count > 0,
|
||||||
|
enter = slideInVertically { it },
|
||||||
|
exit = slideOutVertically { it },
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$count sélectionné(s)",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onClear, enabled = !busy) { Text("Effacer") }
|
||||||
|
Button(onClick = onConfirm, enabled = !busy) {
|
||||||
|
if (busy) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Rounded.Download, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.browse
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import fr.tuxgyver.cachette.AppContainer
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import fr.tuxgyver.cachette.ui.components.MediaRow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parcours d'une source déjà présente dans la bibliothèque : on y choisit, un par un ou en lot,
|
||||||
|
* ce qui doit être gardé sur l'appareil.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SourceBrowseScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
source: SourceKind,
|
||||||
|
currentMediaId: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
|
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val ofSource = remember(entries, source) { entries.filter { it.source == source } }
|
||||||
|
val collections = remember(ofSource) { ofSource.map { it.subtitle }.distinct().sorted() }
|
||||||
|
|
||||||
|
var collection by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||||
|
|
||||||
|
val visible = remember(ofSource, collection) {
|
||||||
|
if (collection == null) ofSource else ofSource.filter { it.subtitle == collection }
|
||||||
|
}
|
||||||
|
val selectable = remember(visible, offlineStates) {
|
||||||
|
visible.filter { it.isRemote && offlineStates[it.id]?.isComplete != true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// La barre de titre occupe déjà l'espace haut du Scaffold : la liste ne reprend que le bas.
|
||||||
|
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(source.label) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (selectable.isNotEmpty()) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
selection = if (selection.size == selectable.size) emptySet()
|
||||||
|
else selectable.mapTo(mutableSetOf()) { it.id }
|
||||||
|
}) {
|
||||||
|
Text(if (selection.size == selectable.size) "Aucun" else "Tout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (collections.size > 1) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = collection == null,
|
||||||
|
onClick = { collection = null },
|
||||||
|
label = { Text("Tout") },
|
||||||
|
)
|
||||||
|
collections.forEach { name ->
|
||||||
|
FilterChip(
|
||||||
|
selected = collection == name,
|
||||||
|
onClick = { collection = name },
|
||||||
|
label = { Text(name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Aucun contenu pour cette source. Ajoutez-en depuis l'écran Sources.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = listPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(visible, key = { it.id }) { entry ->
|
||||||
|
val canSelect = entry.isRemote && offlineStates[entry.id]?.isComplete != true
|
||||||
|
MediaRow(
|
||||||
|
entry = entry,
|
||||||
|
offline = offlineStates[entry.id],
|
||||||
|
isCurrent = entry.id == currentMediaId,
|
||||||
|
selected = if (canSelect) entry.id in selection else null,
|
||||||
|
onClick = {
|
||||||
|
if (canSelect) {
|
||||||
|
selection = if (entry.id in selection) selection - entry.id
|
||||||
|
else selection + entry.id
|
||||||
|
} else {
|
||||||
|
onPlay(visible, visible.indexOf(entry))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDownload = { container.downloads.download(entry) },
|
||||||
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.size(88.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionBar(
|
||||||
|
count = selection.size,
|
||||||
|
actionLabel = "Télécharger",
|
||||||
|
busy = false,
|
||||||
|
onClear = { selection = emptySet() },
|
||||||
|
onConfirm = {
|
||||||
|
visible.filter { it.id in selection }.forEach(container.downloads::download)
|
||||||
|
selection = emptySet()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(contentPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.components
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Size
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.MusicNote
|
||||||
|
import androidx.compose.material.icons.rounded.Videocam
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vignette d'un média.
|
||||||
|
*
|
||||||
|
* Trois cas, un seul point d'entrée : image distante (Coil), vignette MediaStore (ContentResolver),
|
||||||
|
* et à défaut un dégradé déterministe dérivé du titre — jamais de case vide.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Artwork(
|
||||||
|
uri: String?,
|
||||||
|
title: String,
|
||||||
|
kind: MediaKind,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
shape: Shape,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val gradient = remember(title) { gradientFor(title) }
|
||||||
|
var thumbnail by remember(uri) { mutableStateOf<Bitmap?>(null) }
|
||||||
|
var failed by remember(uri) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val needsThumbnail = uri != null && uri.startsWith("content://") && kind == MediaKind.VIDEO
|
||||||
|
|
||||||
|
LaunchedEffect(uri, needsThumbnail) {
|
||||||
|
if (!needsThumbnail || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@LaunchedEffect
|
||||||
|
thumbnail = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.loadThumbnail(uri!!.toUri(), Size(320, 320), null)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(Brush.linearGradient(gradient)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val bitmap = thumbnail
|
||||||
|
when {
|
||||||
|
bitmap != null -> Image(
|
||||||
|
bitmap = bitmap.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
uri != null && !needsThumbnail && !failed -> AsyncImage(
|
||||||
|
model = uri,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
onError = { failed = true },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> Icon(
|
||||||
|
imageVector = if (kind == MediaKind.VIDEO) Icons.Rounded.Videocam else Icons.Rounded.MusicNote,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.85f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dégradé stable tiré du titre : deux fois le même média, deux fois la même couleur.
|
||||||
|
* Les paires sont choisies dans la famille chromatique de l'application, pour que les vignettes
|
||||||
|
* sans pochette restent variées sans jurer avec le reste de l'interface.
|
||||||
|
*/
|
||||||
|
private val PLACEHOLDER_GRADIENTS = listOf(
|
||||||
|
Color(0xFF6B4EFF) to Color(0xFF3FD8C0),
|
||||||
|
Color(0xFF8E5BFF) to Color(0xFFE0629B),
|
||||||
|
Color(0xFF3A7BFF) to Color(0xFF6B4EFF),
|
||||||
|
Color(0xFF11A894) to Color(0xFF3A7BFF),
|
||||||
|
Color(0xFFE0629B) to Color(0xFFFF9A62),
|
||||||
|
Color(0xFF4E3D8C) to Color(0xFF11A894),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun gradientFor(seed: String): List<Color> {
|
||||||
|
val index = ((seed.hashCode() % PLACEHOLDER_GRADIENTS.size) + PLACEHOLDER_GRADIENTS.size) %
|
||||||
|
PLACEHOLDER_GRADIENTS.size
|
||||||
|
val (start, end) = PLACEHOLDER_GRADIENTS[index]
|
||||||
|
return listOf(start, end)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.components
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
fun formatDuration(millis: Long): String {
|
||||||
|
if (millis <= 0) return "--:--"
|
||||||
|
val totalSeconds = millis / 1000
|
||||||
|
val hours = totalSeconds / 3600
|
||||||
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return if (hours > 0) {
|
||||||
|
String.format(Locale.FRANCE, "%d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
} else {
|
||||||
|
String.format(Locale.FRANCE, "%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatBytes(bytes: Long): String = when {
|
||||||
|
bytes <= 0 -> "0 Mo"
|
||||||
|
bytes < 1_000_000 -> String.format(Locale.FRANCE, "%.0f Ko", bytes / 1_000.0)
|
||||||
|
bytes < 1_000_000_000 -> String.format(Locale.FRANCE, "%.1f Mo", bytes / 1_000_000.0)
|
||||||
|
else -> String.format(Locale.FRANCE, "%.2f Go", bytes / 1_000_000_000.0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.CheckCircle
|
||||||
|
import androidx.compose.material.icons.rounded.Download
|
||||||
|
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.rounded.GraphicEq
|
||||||
|
import androidx.compose.material.icons.rounded.Smartphone
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.offline.OfflineState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaRow(
|
||||||
|
entry: MediaEntry,
|
||||||
|
offline: OfflineState?,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onRemoveOffline: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
/** Non nul en mode sélection : la ligne porte alors une case à cocher au lieu de son action. */
|
||||||
|
selected: Boolean? = null,
|
||||||
|
) {
|
||||||
|
val background by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
selected == true -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
isCurrent -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
label = "row-background",
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = background,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Artwork(
|
||||||
|
uri = entry.artworkUri,
|
||||||
|
title = entry.title,
|
||||||
|
kind = entry.kind,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(2.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = entry.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = " · ${formatDuration(entry.durationMs)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
if (isCurrent && selected == null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.GraphicEq,
|
||||||
|
contentDescription = "En cours de lecture",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected != null) {
|
||||||
|
Checkbox(checked = selected, onCheckedChange = { onClick() })
|
||||||
|
} else {
|
||||||
|
OfflineAction(
|
||||||
|
entry = entry,
|
||||||
|
offline = offline,
|
||||||
|
onDownload = onDownload,
|
||||||
|
onRemoveOffline = onRemoveOffline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OfflineAction(
|
||||||
|
entry: MediaEntry,
|
||||||
|
offline: OfflineState?,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onRemoveOffline: () -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
!entry.isRemote -> Icon(
|
||||||
|
imageVector = Icons.Rounded.Smartphone,
|
||||||
|
contentDescription = "Déjà sur l'appareil",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(12.dp).size(20.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
offline?.isComplete == true -> IconButton(onClick = onRemoveOffline) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.CheckCircle,
|
||||||
|
contentDescription = "Disponible hors connexion — appuyer pour libérer",
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
offline?.isActive == true -> Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
) {
|
||||||
|
if (offline.isRunning && offline.percent > 0f) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { offline.percent / 100f },
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offline?.isFailed == true -> IconButton(onClick = onDownload) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.ErrorOutline,
|
||||||
|
contentDescription = "Échec — réessayer",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> IconButton(onClick = onDownload) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Download,
|
||||||
|
contentDescription = "Mettre de côté pour plus tard",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.library
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import fr.tuxgyver.cachette.AppContainer
|
||||||
|
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.offline.OfflineState
|
||||||
|
import fr.tuxgyver.cachette.ui.components.MediaRow
|
||||||
|
|
||||||
|
private enum class LibraryFilter(val label: String) {
|
||||||
|
ALL("Tout"),
|
||||||
|
AUDIO("Musique"),
|
||||||
|
VIDEO("Vidéo"),
|
||||||
|
OFFLINE("Hors ligne"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LibraryScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
currentMediaId: String?,
|
||||||
|
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
|
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||||
|
val scanning by container.library.scanning.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var filter by rememberSaveable { mutableStateOf(LibraryFilter.ALL) }
|
||||||
|
var hasPermission by remember { mutableStateOf(container.library.hasLocalPermission()) }
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { granted ->
|
||||||
|
hasPermission = granted.values.any { it }
|
||||||
|
if (hasPermission) container.library.refreshLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(hasPermission) {
|
||||||
|
if (hasPermission) container.library.refreshLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
val visible = remember(entries, offlineStates, query, filter) {
|
||||||
|
entries.filter { entry ->
|
||||||
|
matchesQuery(entry, query) && matchesFilter(entry, filter, offlineStates[entry.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Bibliothèque",
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${entries.size} éléments · ${offlineStates.values.count { it.isComplete }} mis de côté",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
placeholder = { Text("Rechercher un titre, un artiste…") },
|
||||||
|
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
LibraryFilter.entries.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = filter == option,
|
||||||
|
onClick = { filter = option },
|
||||||
|
label = { Text(option.label) },
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
colors = FilterChipDefaults.filterChipColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanning) {
|
||||||
|
item {
|
||||||
|
LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
item {
|
||||||
|
PermissionCard(
|
||||||
|
onGrant = { permissionLauncher.launch(LocalMediaScanner.requiredPermissions()) }
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible.isEmpty()) {
|
||||||
|
item { EmptyState(hasAnything = entries.isNotEmpty()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
items(visible, key = { it.id }) { entry ->
|
||||||
|
MediaRow(
|
||||||
|
entry = entry,
|
||||||
|
offline = offlineStates[entry.id],
|
||||||
|
isCurrent = entry.id == currentMediaId,
|
||||||
|
onClick = { onPlay(visible, visible.indexOf(entry)) },
|
||||||
|
onDownload = { container.downloads.download(entry) },
|
||||||
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesQuery(entry: MediaEntry, query: String): Boolean {
|
||||||
|
if (query.isBlank()) return true
|
||||||
|
val needle = query.trim().lowercase()
|
||||||
|
return entry.title.lowercase().contains(needle) || entry.subtitle.lowercase().contains(needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesFilter(entry: MediaEntry, filter: LibraryFilter, offline: OfflineState?): Boolean =
|
||||||
|
when (filter) {
|
||||||
|
LibraryFilter.ALL -> true
|
||||||
|
LibraryFilter.AUDIO -> entry.kind == MediaKind.AUDIO
|
||||||
|
LibraryFilter.VIDEO -> entry.kind == MediaKind.VIDEO
|
||||||
|
LibraryFilter.OFFLINE -> !entry.isRemote || offline?.isComplete == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionCard(onGrant: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp)) {
|
||||||
|
Text("Accéder à vos médias", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text(
|
||||||
|
"Cachette lit la musique et les vidéos déjà présentes sur l'appareil. Rien n'est envoyé ailleurs.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(14.dp))
|
||||||
|
Button(onClick = onGrant) { Text("Autoriser") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState(hasAnything: Boolean) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 56.dp),
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.LibraryMusic,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Text(
|
||||||
|
text = if (hasAnything) "Rien ne correspond à cette recherche"
|
||||||
|
else "Votre bibliothèque est vide",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (!hasAnything) {
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Ajoutez une source pour commencer à remplir votre cachette.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.offline
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import fr.tuxgyver.cachette.AppContainer
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.ui.components.MediaRow
|
||||||
|
import fr.tuxgyver.cachette.ui.components.formatBytes
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DownloadsScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
currentMediaId: String?,
|
||||||
|
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
|
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||||
|
var confirmClear by remember { mutableStateOf(false) }
|
||||||
|
var cacheSize by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
LaunchedEffect(offlineStates) {
|
||||||
|
cacheSize = withContext(Dispatchers.IO) { container.offline.cacheSizeBytes() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val cached = remember(entries, offlineStates) {
|
||||||
|
entries.filter { offlineStates[it.id] != null }
|
||||||
|
.sortedByDescending { offlineStates[it.id]?.isActive == true }
|
||||||
|
}
|
||||||
|
val completed = cached.count { offlineStates[it.id]?.isComplete == true }
|
||||||
|
val running = cached.count { offlineStates[it.id]?.isActive == true }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(Modifier.padding(top = 12.dp)) {
|
||||||
|
Text("Hors ligne", style = MaterialTheme.typography.displaySmall)
|
||||||
|
Spacer(Modifier.size(14.dp))
|
||||||
|
StorageCard(
|
||||||
|
cacheSize = cacheSize,
|
||||||
|
completed = completed,
|
||||||
|
running = running,
|
||||||
|
onClear = { confirmClear = true },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.isEmpty()) {
|
||||||
|
item { EmptyOffline() }
|
||||||
|
}
|
||||||
|
|
||||||
|
items(cached, key = { it.id }) { entry ->
|
||||||
|
val state = offlineStates[entry.id]
|
||||||
|
Column {
|
||||||
|
MediaRow(
|
||||||
|
entry = entry,
|
||||||
|
offline = state,
|
||||||
|
isCurrent = entry.id == currentMediaId,
|
||||||
|
onClick = { onPlay(cached, cached.indexOf(entry)) },
|
||||||
|
onDownload = { container.downloads.download(entry) },
|
||||||
|
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||||
|
)
|
||||||
|
if (state?.isActive == true) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { (state.percent / 100f).coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmClear) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmClear = false },
|
||||||
|
title = { Text("Vider la cachette ?") },
|
||||||
|
text = {
|
||||||
|
Text("Tout le contenu mis de côté sera supprimé de l'appareil (${formatBytes(cacheSize)}). Les fichiers déjà présents sur l'appareil ne sont pas touchés.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
container.downloads.removeAll()
|
||||||
|
confirmClear = false
|
||||||
|
}) { Text("Tout libérer") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmClear = false }) { Text("Annuler") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StorageCard(
|
||||||
|
cacheSize: Long,
|
||||||
|
completed: Int,
|
||||||
|
running: Int,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = formatBytes(cacheSize),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = buildString {
|
||||||
|
append("$completed élément(s) disponibles sans réseau")
|
||||||
|
if (running > 0) append(" · $running en cours")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (completed > 0 || running > 0) {
|
||||||
|
TextButton(onClick = onClear) { Text("Tout libérer") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyOffline() {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 56.dp),
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.CloudOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Text("Rien de mis de côté", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Appuyez sur l'icône de téléchargement d'un média distant pour le garder hors connexion.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.foundation.basicMarquee
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Pause
|
||||||
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
|
import androidx.compose.material.icons.rounded.SkipNext
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import fr.tuxgyver.cachette.playback.PlayerUiState
|
||||||
|
import fr.tuxgyver.cachette.ui.components.Artwork
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MiniPlayer(
|
||||||
|
state: PlayerUiState,
|
||||||
|
positionMs: Long,
|
||||||
|
onExpand: () -> Unit,
|
||||||
|
onTogglePlay: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val progress = if (state.durationMs > 0) {
|
||||||
|
(positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onExpand),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Artwork(
|
||||||
|
uri = state.artworkUri,
|
||||||
|
title = state.title,
|
||||||
|
kind = state.kind,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = state.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.basicMarquee(),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onTogglePlay) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
|
||||||
|
contentDescription = if (state.isPlaying) "Pause" else "Lecture",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.hasNext) {
|
||||||
|
IconButton(onClick = onNext) {
|
||||||
|
Icon(Icons.Rounded.SkipNext, contentDescription = "Suivant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.size(width = 0.dp, height = 2.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.player
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.basicMarquee
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.CheckCircle
|
||||||
|
import androidx.compose.material.icons.rounded.Download
|
||||||
|
import androidx.compose.material.icons.rounded.Forward30
|
||||||
|
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.rounded.Pause
|
||||||
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
|
import androidx.compose.material.icons.rounded.Replay10
|
||||||
|
import androidx.compose.material.icons.rounded.SkipNext
|
||||||
|
import androidx.compose.material.icons.rounded.SkipPrevious
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import fr.tuxgyver.cachette.model.MediaEntry
|
||||||
|
import fr.tuxgyver.cachette.model.MediaKind
|
||||||
|
import fr.tuxgyver.cachette.offline.OfflineState
|
||||||
|
import fr.tuxgyver.cachette.playback.PlayerUiState
|
||||||
|
import fr.tuxgyver.cachette.ui.components.Artwork
|
||||||
|
import fr.tuxgyver.cachette.ui.components.formatDuration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NowPlayingScreen(
|
||||||
|
state: PlayerUiState,
|
||||||
|
positionMs: Long,
|
||||||
|
entry: MediaEntry?,
|
||||||
|
offline: OfflineState?,
|
||||||
|
player: Player?,
|
||||||
|
onCollapse: () -> Unit,
|
||||||
|
onTogglePlay: () -> Unit,
|
||||||
|
onSeek: (Long) -> Unit,
|
||||||
|
onSkipBy: (Long) -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
onPrevious: () -> Unit,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onRemoveOffline: () -> Unit,
|
||||||
|
) {
|
||||||
|
var scrubbing by remember { mutableStateOf<Float?>(null) }
|
||||||
|
val displayedPosition = scrubbing?.let { (it * state.durationMs).toLong() } ?: positionMs
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
listOf(
|
||||||
|
MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
MaterialTheme.colorScheme.background,
|
||||||
|
MaterialTheme.colorScheme.background,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onCollapse) {
|
||||||
|
Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Réduire")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "En lecture",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
OfflineToggle(entry, offline, onDownload, onRemoveOffline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(0.35f))
|
||||||
|
|
||||||
|
if (state.kind == MediaKind.VIDEO && player != null) {
|
||||||
|
VideoSurface(
|
||||||
|
player = player,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
.background(Color.Black),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Artwork(
|
||||||
|
uri = state.artworkUri,
|
||||||
|
title = state.title,
|
||||||
|
kind = state.kind,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.88f)
|
||||||
|
.aspectRatio(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(0.65f))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = state.title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.basicMarquee(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
Text(
|
||||||
|
text = state.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(20.dp))
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = scrubbing ?: if (state.durationMs > 0) {
|
||||||
|
(positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
||||||
|
} else 0f,
|
||||||
|
onValueChange = { scrubbing = it },
|
||||||
|
onValueChangeFinished = {
|
||||||
|
scrubbing?.let { onSeek((it * state.durationMs).toLong()) }
|
||||||
|
scrubbing = null
|
||||||
|
},
|
||||||
|
enabled = state.durationMs > 0,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(formatDuration(displayedPosition), style = MaterialTheme.typography.labelMedium)
|
||||||
|
Text(formatDuration(state.durationMs), style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onPrevious, enabled = state.hasPrevious) {
|
||||||
|
Icon(Icons.Rounded.SkipPrevious, contentDescription = "Précédent")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { onSkipBy(-10_000) }) {
|
||||||
|
Icon(Icons.Rounded.Replay10, contentDescription = "Reculer de 10 secondes")
|
||||||
|
}
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = onTogglePlay,
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(),
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
) {
|
||||||
|
if (state.isBuffering) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
|
||||||
|
contentDescription = if (state.isPlaying) "Pause" else "Lecture",
|
||||||
|
modifier = Modifier.size(34.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = { onSkipBy(30_000) }) {
|
||||||
|
Icon(Icons.Rounded.Forward30, contentDescription = "Avancer de 30 secondes")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNext, enabled = state.hasNext) {
|
||||||
|
Icon(Icons.Rounded.SkipNext, contentDescription = "Suivant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OfflineToggle(
|
||||||
|
entry: MediaEntry?,
|
||||||
|
offline: OfflineState?,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onRemoveOffline: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (entry == null || !entry.isRemote) {
|
||||||
|
Spacer(Modifier.size(48.dp))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
offline?.isComplete == true -> IconButton(onClick = onRemoveOffline) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.CheckCircle,
|
||||||
|
contentDescription = "Disponible hors connexion — appuyer pour libérer",
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
offline?.isActive == true -> Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> IconButton(onClick = onDownload) {
|
||||||
|
Icon(Icons.Rounded.Download, contentDescription = "Mettre de côté pour plus tard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun VideoSurface(player: Player, modifier: Modifier = Modifier) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
PlayerView(context).apply {
|
||||||
|
useController = false
|
||||||
|
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { view -> view.player = player },
|
||||||
|
onRelease = { view -> view.player = null },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.sources
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||||
|
import androidx.compose.material.icons.rounded.Public
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
import androidx.compose.material.icons.rounded.Info
|
||||||
|
import androidx.compose.material.icons.rounded.Link
|
||||||
|
import androidx.compose.material.icons.rounded.Podcasts
|
||||||
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
|
import androidx.compose.material.icons.rounded.Smartphone
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import fr.tuxgyver.cachette.AppContainer
|
||||||
|
import fr.tuxgyver.cachette.model.SourceKind
|
||||||
|
import fr.tuxgyver.cachette.sources.CatalogId
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourcesScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
onBrowseSource: (SourceKind) -> Unit,
|
||||||
|
onBrowseCatalog: (CatalogId) -> Unit,
|
||||||
|
) {
|
||||||
|
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||||
|
var sheet by remember { mutableStateOf<ImportKind?>(null) }
|
||||||
|
|
||||||
|
val deviceCount = entries.count { it.source == SourceKind.LOCAL }
|
||||||
|
val linkCount = entries.count { it.source == SourceKind.URL }
|
||||||
|
val podcastCount = entries.count { it.source == SourceKind.PODCAST }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(Modifier.padding(top = 12.dp)) {
|
||||||
|
Text("Sources", style = MaterialTheme.typography.displaySmall)
|
||||||
|
Text(
|
||||||
|
"D'où vient le contenu que vous gardez hors connexion.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SourceCard(
|
||||||
|
icon = Icons.Rounded.Smartphone,
|
||||||
|
title = "Appareil",
|
||||||
|
status = "$deviceCount éléments",
|
||||||
|
description = "Musique et vidéos déjà présentes sur le téléphone. Toujours disponibles.",
|
||||||
|
actionLabel = "Actualiser",
|
||||||
|
actionIcon = Icons.Rounded.Refresh,
|
||||||
|
onAction = { container.library.refreshLocal() },
|
||||||
|
onOpen = { onBrowseSource(SourceKind.LOCAL) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SourceCard(
|
||||||
|
icon = Icons.Rounded.Link,
|
||||||
|
title = "Lien direct",
|
||||||
|
status = "$linkCount éléments",
|
||||||
|
description = "Une URL vers un fichier audio ou vidéo. Le fichier est ensuite copié dans la cachette.",
|
||||||
|
actionLabel = "Ajouter un lien",
|
||||||
|
onAction = { sheet = ImportKind.URL },
|
||||||
|
onOpen = { onBrowseSource(SourceKind.URL) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SourceCard(
|
||||||
|
icon = Icons.Rounded.Podcasts,
|
||||||
|
title = "Podcast",
|
||||||
|
status = "$podcastCount épisodes",
|
||||||
|
description = "Un flux RSS. Les épisodes deviennent téléchargeables un par un.",
|
||||||
|
actionLabel = "Ajouter un flux",
|
||||||
|
onAction = { sheet = ImportKind.RSS },
|
||||||
|
onOpen = { onBrowseSource(SourceKind.PODCAST) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Rechercher, choisir, emporter. Aucun compte requis.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(CatalogId.entries) { catalog ->
|
||||||
|
SourceCard(
|
||||||
|
icon = if (catalog == CatalogId.LIBRIVOX) Icons.AutoMirrored.Rounded.MenuBook else Icons.Rounded.Public,
|
||||||
|
title = catalog.title,
|
||||||
|
status = catalog.license,
|
||||||
|
description = catalog.description,
|
||||||
|
actionLabel = "Parcourir",
|
||||||
|
actionIcon = Icons.Rounded.Search,
|
||||||
|
onAction = { onBrowseCatalog(catalog) },
|
||||||
|
onOpen = { onBrowseCatalog(catalog) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
unavailableSourceCards(
|
||||||
|
listOf(
|
||||||
|
UnavailableSource(
|
||||||
|
"Spotify",
|
||||||
|
"Le SDK Spotify ne donne pas accès au flux audio : la lecture hors connexion n'existe que dans l'application Spotify. Un podcast diffusé aussi en RSS public peut en revanche être ajouté ci-dessus.",
|
||||||
|
),
|
||||||
|
UnavailableSource(
|
||||||
|
"YouTube",
|
||||||
|
"Les conditions d'utilisation interdisent d'extraire ou de stocker les flux. Le hors-ligne passe par YouTube Premium, dans leur application.",
|
||||||
|
),
|
||||||
|
UnavailableSource(
|
||||||
|
"Google Drive / Photos",
|
||||||
|
"Techniquement possible pour vos propres fichiers, mais cela demande un identifiant OAuth Google propre à votre compte développeur, qui n'est pas fourni ici.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = sheet
|
||||||
|
if (current != null) {
|
||||||
|
ImportSheet(
|
||||||
|
kind = current,
|
||||||
|
container = container,
|
||||||
|
onDismiss = { sheet = null },
|
||||||
|
onMessage = onMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class UnavailableSource(val title: String, val explanation: String)
|
||||||
|
|
||||||
|
private fun androidx.compose.foundation.lazy.LazyListScope.unavailableSourceCards(sources: List<UnavailableSource>) {
|
||||||
|
sources.forEach { source ->
|
||||||
|
item {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(16.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Info,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(source.title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
Text(
|
||||||
|
source.explanation,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SourceCard(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
status: String,
|
||||||
|
description: String,
|
||||||
|
actionLabel: String,
|
||||||
|
actionIcon: ImageVector? = null,
|
||||||
|
onAction: () -> Unit,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onOpen),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
status,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||||
|
contentDescription = "Parcourir",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Button(onClick = onAction) {
|
||||||
|
if (actionIcon != null) {
|
||||||
|
Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class ImportKind(val title: String, val hint: String) {
|
||||||
|
URL("Ajouter un lien direct", "https://exemple.org/morceau.mp3"),
|
||||||
|
RSS("Ajouter un flux podcast", "https://exemple.org/podcast.xml"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ImportSheet(
|
||||||
|
kind: ImportKind,
|
||||||
|
container: AppContainer,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var mode by remember { mutableStateOf(kind) }
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
Text(mode.title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ImportKind.entries.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = mode == option,
|
||||||
|
onClick = { mode = option; error = null },
|
||||||
|
label = { Text(if (option == ImportKind.URL) "Lien direct" else "Flux podcast") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = url,
|
||||||
|
onValueChange = { url = it; error = null },
|
||||||
|
placeholder = { Text(mode.hint) },
|
||||||
|
singleLine = true,
|
||||||
|
isError = error != null,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(20.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !busy) { Text("Annuler") }
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Button(
|
||||||
|
enabled = !busy && url.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
busy = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
val outcome = runImport(container, mode, url)
|
||||||
|
busy = false
|
||||||
|
outcome.fold(
|
||||||
|
onSuccess = { message ->
|
||||||
|
onMessage(message)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
onFailure = { error = it.message ?: "Import impossible" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (busy) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
Text("Importer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runImport(
|
||||||
|
container: AppContainer,
|
||||||
|
kind: ImportKind,
|
||||||
|
url: String,
|
||||||
|
): Result<String> = when (kind) {
|
||||||
|
ImportKind.URL -> container.urlImporter.import(url).mapCatching { entry ->
|
||||||
|
val added = container.imported.add(listOf(entry))
|
||||||
|
if (added == 0) "« ${entry.title} » est déjà dans la bibliothèque"
|
||||||
|
else "« ${entry.title} » ajouté"
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportKind.RSS -> container.rssImporter.import(url).mapCatching { feed ->
|
||||||
|
val added = container.imported.add(feed.episodes)
|
||||||
|
if (added == 0) "${feed.title} : aucun nouvel épisode"
|
||||||
|
else "${feed.title} : $added épisode(s) ajouté(s)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package fr.tuxgyver.cachette.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val Violet = Color(0xFF6B4EFF)
|
||||||
|
private val VioletDeep = Color(0xFF1B1033)
|
||||||
|
private val Mint = Color(0xFF3FD8C0)
|
||||||
|
private val Sand = Color(0xFFFFF7F2)
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme(
|
||||||
|
primary = Violet,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = Color(0xFFE6DEFF),
|
||||||
|
onPrimaryContainer = Color(0xFF1B1033),
|
||||||
|
secondary = Color(0xFF11A894),
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = Color(0xFFC9F5EC),
|
||||||
|
onSecondaryContainer = Color(0xFF04231E),
|
||||||
|
tertiary = Color(0xFFE0629B),
|
||||||
|
background = Sand,
|
||||||
|
onBackground = Color(0xFF1C1B22),
|
||||||
|
surface = Color(0xFFFFFBFF),
|
||||||
|
onSurface = Color(0xFF1C1B22),
|
||||||
|
surfaceVariant = Color(0xFFEDE5F2),
|
||||||
|
onSurfaceVariant = Color(0xFF4A4458),
|
||||||
|
outlineVariant = Color(0xFFDCD3E4),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors = darkColorScheme(
|
||||||
|
primary = Color(0xFFB79CFF),
|
||||||
|
onPrimary = Color(0xFF2A1A5E),
|
||||||
|
primaryContainer = Color(0xFF3D2A7A),
|
||||||
|
onPrimaryContainer = Color(0xFFE9DDFF),
|
||||||
|
secondary = Mint,
|
||||||
|
onSecondary = Color(0xFF00382F),
|
||||||
|
secondaryContainer = Color(0xFF005046),
|
||||||
|
onSecondaryContainer = Color(0xFFB6F2E6),
|
||||||
|
tertiary = Color(0xFFFFB0CC),
|
||||||
|
background = Color(0xFF120D1C),
|
||||||
|
onBackground = Color(0xFFE8E0F0),
|
||||||
|
surface = VioletDeep,
|
||||||
|
onSurface = Color(0xFFE8E0F0),
|
||||||
|
surfaceVariant = Color(0xFF2A2038),
|
||||||
|
onSurfaceVariant = Color(0xFFC9C0D8),
|
||||||
|
outlineVariant = Color(0xFF3A3048),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val CachetteShapes = Shapes(
|
||||||
|
extraSmall = RoundedCornerShape(8.dp),
|
||||||
|
small = RoundedCornerShape(14.dp),
|
||||||
|
medium = RoundedCornerShape(20.dp),
|
||||||
|
large = RoundedCornerShape(28.dp),
|
||||||
|
extraLarge = RoundedCornerShape(36.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val CachetteTypography = Typography().run {
|
||||||
|
copy(
|
||||||
|
displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp),
|
||||||
|
headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.4).sp),
|
||||||
|
headlineSmall = headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
titleLarge = titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
titleMedium = titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
labelLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 14.sp, letterSpacing = 0.1.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CachetteTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
// Palette fixe plutôt que couleur dynamique : elle garantit le contraste des libellés
|
||||||
|
// (un fond d'écran monochrome produit un thème Material You sans contraste) et donne à
|
||||||
|
// l'application une identité stable d'un appareil à l'autre.
|
||||||
|
val colors = if (darkTheme) DarkColors else LightColors
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colors,
|
||||||
|
shapes = CachetteShapes,
|
||||||
|
typography = CachetteTypography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="splash_background">#FF120D1C</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.Cachette" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="splash_background">#FFF7F2FA</color>
|
||||||
|
<color name="ic_launcher_background">#FF1B1033</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Cachette</string>
|
||||||
|
<string name="download_channel_name">Téléchargements</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.Cachette" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Le contenu mis en cache reste sur l'appareil : ni sauvegarde cloud, ni transfert. -->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.13.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" apply false
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 289 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 327 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Cachette"
|
||||||
|
include(":app")
|
||||||
Reference in new issue
Block a user