commit 992fe88cc4a26594f25b2723fccfb8d53627633b Author: Johnny Date: Fri Sep 11 19:55:08 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02ca64e --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.iml +.gradle/ +local.properties +.idea/ +.DS_Store +build/ +captures/ +.externalNativeBuild/ +.cxx/ +keystore.properties +*.jks +.gitea-token +Cachette-*.apk diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f874e87 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6f88367 --- /dev/null +++ b/Makefile @@ -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 "" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b29fb5 --- /dev/null +++ b/README.md @@ -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. + +[![Platform](https://img.shields.io/badge/Plateforme-Android%208.0%2B-3DDC84?logo=android&logoColor=white)](https://developer.android.com) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) +[![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) +[![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) +[![Version](https://img.shields.io/badge/Version-1.0.0-6B4EFF)](VERSION) +[![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) + +Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les +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 | +|:---:|:---:|:---:|:---:| +| ![Bibliothèque](docs/screenshots/bibliotheque.png) | ![Lecture](docs/screenshots/lecture.png) | ![Hors ligne](docs/screenshots/hors-ligne.png) | ![Catalogue](docs/screenshots/catalogue.png) | + +--- + +## ✨ 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 `` | +| 🏛️ 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. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..45c1803 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ea1b85d --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +-dontwarn androidx.media3.** +-keep class androidx.media3.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e8b75ee --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt b/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt new file mode 100644 index 0000000..1a38246 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt @@ -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 = 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) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/MainActivity.kt b/app/src/main/java/fr/tuxgyver/cachette/MainActivity.kt new file mode 100644 index 0000000..38e6a0f --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/MainActivity.kt @@ -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) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/library/ImportedStore.kt b/app/src/main/java/fr/tuxgyver/cachette/library/ImportedStore.kt new file mode 100644 index 0000000..388082a --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/library/ImportedStore.kt @@ -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>(emptyList()) + val entries: StateFlow> = _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): 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 = withContext(Dispatchers.IO) { + if (!file.exists()) return@withContext emptyList() + runCatching { json.decodeFromString>(file.readText()) } + .getOrElse { emptyList() } + } + + private suspend fun write(entries: List) = withContext(Dispatchers.IO) { + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(json.encodeToString(entries)) + tmp.renameTo(file) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/library/LibraryRepository.kt b/app/src/main/java/fr/tuxgyver/cachette/library/LibraryRepository.kt new file mode 100644 index 0000000..6bc2a25 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/library/LibraryRepository.kt @@ -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>(emptyList()) + + private val _scanning = MutableStateFlow(false) + val scanning: StateFlow = _scanning.asStateFlow() + + val entries: StateFlow> = + 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) +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/library/LocalMediaScanner.kt b/app/src/main/java/fr/tuxgyver/cachette/library/LocalMediaScanner.kt new file mode 100644 index 0000000..b03640d --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/library/LocalMediaScanner.kt @@ -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 = 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 { + 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() + 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 == "") "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 { + 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() + 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 = + 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) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/model/Media.kt b/app/src/main/java/fr/tuxgyver/cachette/model/Media.kt new file mode 100644 index 0000000..da506ee --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/model/Media.kt @@ -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 +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/offline/CachetteDownloadService.kt b/app/src/main/java/fr/tuxgyver/cachette/offline/CachetteDownloadService.kt new file mode 100644 index 0000000..27c495a --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/offline/CachetteDownloadService.kt @@ -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, + 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" + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/offline/DownloadTracker.kt b/app/src/main/java/fr/tuxgyver/cachette/offline/DownloadTracker.kt new file mode 100644 index 0000000..e69d781 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/offline/DownloadTracker.kt @@ -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>(emptyMap()) + + private val ticker = flow { + while (true) { + emit(Unit) + delay(1_000) + } + } + + val states: StateFlow> = + 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() + 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, + ) +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/offline/OfflineModule.kt b/app/src/main/java/fr/tuxgyver/cachette/offline/OfflineModule.kt new file mode 100644 index 0000000..2535e76 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/offline/OfflineModule.kt @@ -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 + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/playback/MediaItems.kt b/app/src/main/java/fr/tuxgyver/cachette/playback/MediaItems.kt new file mode 100644 index 0000000..78051a0 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/playback/MediaItems.kt @@ -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() diff --git a/app/src/main/java/fr/tuxgyver/cachette/playback/PlaybackService.kt b/app/src/main/java/fr/tuxgyver/cachette/playback/PlaybackService.kt new file mode 100644 index 0000000..bf7925c --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/playback/PlaybackService.kt @@ -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, + ): ListenableFuture> { + val library = (application as CachetteApp).container.library + val resolved = mediaItems.mapTo(mutableListOf()) { item -> + library.entry(item.mediaId)?.toMediaItem() ?: item + } + return Futures.immediateFuture(resolved) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/playback/PlayerController.kt b/app/src/main/java/fr/tuxgyver/cachette/playback/PlayerController.kt new file mode 100644 index 0000000..e84e115 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/playback/PlayerController.kt @@ -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 = _state.asStateFlow() + + val positionMs: StateFlow = 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, 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(), + ) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt new file mode 100644 index 0000000..34f9256 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt @@ -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> = 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> = 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 +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt new file mode 100644 index 0000000..8a1c8dd --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt @@ -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> + + /** + * 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> +} + +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", + ), +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/Http.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/Http.kt new file mode 100644 index 0000000..4f0819a --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/Http.kt @@ -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)") + } diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt new file mode 100644 index 0000000..51b689a --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt @@ -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> = 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> = + 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" } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/RssImporter.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/RssImporter.kt new file mode 100644 index 0000000..7fcdb15 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/RssImporter.kt @@ -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, +) + +/** Importe un flux RSS de podcast : chaque `` devient une entrée téléchargeable. */ +class RssImporter { + + suspend fun import(feedUrl: String): Result = 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() + + 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() + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/UrlImporter.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/UrlImporter.kt new file mode 100644 index 0000000..d629c0f --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/UrlImporter.kt @@ -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 = 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 + } + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt new file mode 100644 index 0000000..e304d5b --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/CachetteRoot.kt @@ -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, 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 + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt new file mode 100644 index 0000000..495849e --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt @@ -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>(emptyList()) } + var selection by remember { mutableStateOf(emptySet()) } + var searching by remember { mutableStateOf(false) } + var adding by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(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() }) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/browse/SelectionBar.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/SelectionBar.kt new file mode 100644 index 0000000..2cb0f72 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/SelectionBar.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/browse/SourceBrowseScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/SourceBrowseScreen.kt new file mode 100644 index 0000000..b6a86f7 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/SourceBrowseScreen.kt @@ -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, 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(null) } + var selection by remember { mutableStateOf(emptySet()) } + + 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), + ) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/components/Artwork.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/components/Artwork.kt new file mode 100644 index 0000000..fdea96a --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/components/Artwork.kt @@ -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(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 { + val index = ((seed.hashCode() % PLACEHOLDER_GRADIENTS.size) + PLACEHOLDER_GRADIENTS.size) % + PLACEHOLDER_GRADIENTS.size + val (start, end) = PLACEHOLDER_GRADIENTS[index] + return listOf(start, end) +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/components/Format.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/components/Format.kt new file mode 100644 index 0000000..430ea5d --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/components/Format.kt @@ -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) +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/components/MediaRow.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/components/MediaRow.kt new file mode 100644 index 0000000..8b40ddf --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/components/MediaRow.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/library/LibraryScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/library/LibraryScreen.kt new file mode 100644 index 0000000..39f1ca4 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/library/LibraryScreen.kt @@ -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, 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, + ) + } + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt new file mode 100644 index 0000000..66099e8 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/offline/DownloadsScreen.kt @@ -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, 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, + ) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/player/MiniPlayer.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/player/MiniPlayer.kt new file mode 100644 index 0000000..d4f5816 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/player/MiniPlayer.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/player/NowPlayingScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/player/NowPlayingScreen.kt new file mode 100644 index 0000000..f33e18b --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/player/NowPlayingScreen.kt @@ -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(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, + ) +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt new file mode 100644 index 0000000..f03fa87 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt @@ -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(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) { + 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(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 = 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)" + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/theme/Theme.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/theme/Theme.kt new file mode 100644 index 0000000..1b31e55 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..3e8179d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_offline.xml b/app/src/main/res/drawable/ic_offline.xml new file mode 100644 index 0000000..ee1f89c --- /dev/null +++ b/app/src/main/res/drawable/ic_offline.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..8920ec3 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,3 @@ + + #FF120D1C + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..341e829 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..33e0484 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + #FFF7F2FA + #FF1B1033 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c5709db --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Cachette + Téléchargements + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..373e184 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..bcde80f --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ff39287 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/docs/screenshots/bibliotheque.png b/docs/screenshots/bibliotheque.png new file mode 100644 index 0000000..4fb4b48 Binary files /dev/null and b/docs/screenshots/bibliotheque.png differ diff --git a/docs/screenshots/catalogue.png b/docs/screenshots/catalogue.png new file mode 100644 index 0000000..6f042a4 Binary files /dev/null and b/docs/screenshots/catalogue.png differ diff --git a/docs/screenshots/hors-ligne.png b/docs/screenshots/hors-ligne.png new file mode 100644 index 0000000..b0d1600 Binary files /dev/null and b/docs/screenshots/hors-ligne.png differ diff --git a/docs/screenshots/lecture.png b/docs/screenshots/lecture.png new file mode 100644 index 0000000..bf3208d Binary files /dev/null and b/docs/screenshots/lecture.png differ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8f2e28c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f81fe55 --- /dev/null +++ b/settings.gradle.kts @@ -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")