Première version : lecteur audio et vidéo hors connexion (1.0.0)
Cachette met des médias de côté pendant qu'il y a du réseau, pour les lire quand il n'y en a plus. - Lecture audio et vidéo en arrière-plan (Media3 MediaSessionService) - Mise en cache explicite, reprise automatique après coupure (WorkManager) - Sources : appareil (MediaStore), lien direct, flux RSS de podcast - Catalogues libres de droits : Internet Archive et LibriVox - Parcours d'une source avec sélection multiple et téléchargement en lot - Interface Jetpack Compose Material 3, thèmes clair et sombre - Makefile catégorisé, publication Gitea déléguée à BorisSkill Le fichier VERSION est la source unique de la version : versionCode en est déduit et la publication s'y réfère.
This commit is contained in:
commit
992fe88cc4
59 files changed
+4619
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "fr.tuxgyver.cachette"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "fr.tuxgyver.cachette"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
|
||||
// Le fichier VERSION est la seule source de vérité : le Makefile et la publication
|
||||
// Gitea lisent le même, et versionCode s'en déduit pour rester monotone.
|
||||
val appVersion = rootProject.file("VERSION").readText().trim()
|
||||
val (major, minor, patch) = appVersion.split(".").map(String::toInt)
|
||||
versionName = appVersion
|
||||
versionCode = major * 10000 + minor * 100 + patch
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
|
||||
val keystore = rootProject.file("keystore.properties")
|
||||
if (keystore.exists()) {
|
||||
val props = Properties().apply { keystore.inputStream().use { load(it) } }
|
||||
signingConfig = signingConfigs.create("release") {
|
||||
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||
storePassword = props.getProperty("storePassword")
|
||||
keyAlias = props.getProperty("keyAlias")
|
||||
keyPassword = props.getProperty("keyPassword")
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("androidx.compose:compose-bom:2025.10.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.4")
|
||||
|
||||
// Lecture, session média et cache hors connexion.
|
||||
implementation("androidx.media3:media3-exoplayer:1.11.1")
|
||||
implementation("androidx.media3:media3-session:1.11.1")
|
||||
implementation("androidx.media3:media3-ui:1.11.1")
|
||||
implementation("androidx.media3:media3-exoplayer-workmanager:1.11.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
-dontwarn androidx.media3.**
|
||||
-keep class androidx.media3.** { *; }
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<application
|
||||
android:name=".CachetteApp"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Cachette">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Cachette"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".playback.PlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".offline.CachetteDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,57 @@
|
||||
package fr.tuxgyver.cachette
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import fr.tuxgyver.cachette.library.ImportedStore
|
||||
import fr.tuxgyver.cachette.library.LibraryRepository
|
||||
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
||||
import fr.tuxgyver.cachette.offline.DownloadTracker
|
||||
import fr.tuxgyver.cachette.offline.OfflineModule
|
||||
import fr.tuxgyver.cachette.playback.PlayerController
|
||||
import fr.tuxgyver.cachette.sources.ArchiveOrgCatalog
|
||||
import fr.tuxgyver.cachette.sources.Catalog
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
|
||||
import fr.tuxgyver.cachette.sources.RssImporter
|
||||
import fr.tuxgyver.cachette.sources.UrlImporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
/**
|
||||
* Dépendances de l'application, assemblées à la main.
|
||||
*
|
||||
* Le gestionnaire de téléchargement Media3 exige le thread principal : tout est donc construit
|
||||
* ici, au démarrage, et partagé tel quel avec les services.
|
||||
*/
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
val offline = OfflineModule(context)
|
||||
val downloads = DownloadTracker(context, offline.downloadManager, scope)
|
||||
|
||||
val imported = ImportedStore(context, scope)
|
||||
val library = LibraryRepository(LocalMediaScanner(context), imported, scope)
|
||||
|
||||
val urlImporter = UrlImporter()
|
||||
val rssImporter = RssImporter()
|
||||
|
||||
val catalogs: Map<CatalogId, Catalog> = listOf(
|
||||
ArchiveOrgCatalog(),
|
||||
LibriVoxCatalog(rssImporter),
|
||||
).associateBy { it.id }
|
||||
|
||||
val player = PlayerController(context, scope) { id -> library.entry(id) }
|
||||
}
|
||||
|
||||
class CachetteApp : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package fr.tuxgyver.cachette
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import fr.tuxgyver.cachette.ui.CachetteRoot
|
||||
import fr.tuxgyver.cachette.ui.theme.CachetteTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val notificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* informatif */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val container = (application as CachetteApp).container
|
||||
container.player.connect()
|
||||
askForNotificationsIfNeeded()
|
||||
|
||||
setContent {
|
||||
CachetteTheme {
|
||||
CachetteRoot(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (isFinishing) {
|
||||
(application as CachetteApp).container.player.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** La notification porte les commandes de lecture et la progression des téléchargements. */
|
||||
private fun askForNotificationsIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package fr.tuxgyver.cachette.library
|
||||
|
||||
import android.content.Context
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Persistance des médias importés (liens directs, épisodes de podcast).
|
||||
*
|
||||
* Le contenu local vient de MediaStore et l'état de cache vient de Media3 : ce fichier ne détient
|
||||
* donc que ce dont personne d'autre n'est propriétaire, la liste des références distantes ajoutées.
|
||||
*/
|
||||
class ImportedStore(context: Context, private val scope: CoroutineScope) {
|
||||
|
||||
private val file = File(context.filesDir, "imported.json")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _entries = MutableStateFlow<List<MediaEntry>>(emptyList())
|
||||
val entries: StateFlow<List<MediaEntry>> = _entries.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch { _entries.value = read() }
|
||||
}
|
||||
|
||||
/** Ajoute les entrées inconnues et renvoie le nombre réellement ajouté. */
|
||||
suspend fun add(candidates: List<MediaEntry>): Int = mutex.withLock {
|
||||
val known = _entries.value.mapTo(HashSet()) { it.id }
|
||||
val fresh = candidates.filterNot { it.id in known }
|
||||
if (fresh.isEmpty()) return@withLock 0
|
||||
val merged = fresh + _entries.value
|
||||
_entries.value = merged
|
||||
write(merged)
|
||||
fresh.size
|
||||
}
|
||||
|
||||
suspend fun remove(id: String) = mutex.withLock {
|
||||
val merged = _entries.value.filterNot { it.id == id }
|
||||
if (merged.size != _entries.value.size) {
|
||||
_entries.value = merged
|
||||
write(merged)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun read(): List<MediaEntry> = withContext(Dispatchers.IO) {
|
||||
if (!file.exists()) return@withContext emptyList()
|
||||
runCatching { json.decodeFromString<List<MediaEntry>>(file.readText()) }
|
||||
.getOrElse { emptyList() }
|
||||
}
|
||||
|
||||
private suspend fun write(entries: List<MediaEntry>) = withContext(Dispatchers.IO) {
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(json.encodeToString(entries))
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package fr.tuxgyver.cachette.library
|
||||
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Vue unifiée de la bibliothèque : contenu de l'appareil + contenu importé. */
|
||||
class LibraryRepository(
|
||||
private val scanner: LocalMediaScanner,
|
||||
private val imported: ImportedStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val local = MutableStateFlow<List<MediaEntry>>(emptyList())
|
||||
|
||||
private val _scanning = MutableStateFlow(false)
|
||||
val scanning: StateFlow<Boolean> = _scanning.asStateFlow()
|
||||
|
||||
val entries: StateFlow<List<MediaEntry>> =
|
||||
combine(local, imported.entries) { device, remote ->
|
||||
(remote + device).sortedByDescending { it.addedAt }
|
||||
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun refreshLocal() {
|
||||
scope.launch {
|
||||
_scanning.value = true
|
||||
try {
|
||||
local.value = scanner.scan()
|
||||
} finally {
|
||||
_scanning.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasLocalPermission(): Boolean = scanner.hasPermission()
|
||||
|
||||
fun entry(id: String): MediaEntry? = entries.value.firstOrNull { it.id == id }
|
||||
|
||||
suspend fun forget(id: String) = imported.remove(id)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package fr.tuxgyver.cachette.library
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.content.ContextCompat
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Lit la bibliothèque de l'appareil via MediaStore.
|
||||
*
|
||||
* MediaStore est déjà l'index du contenu local : on ne le recopie pas dans une base à nous,
|
||||
* on le relit à la demande.
|
||||
*/
|
||||
class LocalMediaScanner(private val context: Context) {
|
||||
|
||||
suspend fun scan(): List<MediaEntry> = withContext(Dispatchers.IO) {
|
||||
if (!hasPermission()) return@withContext emptyList()
|
||||
buildList {
|
||||
addAll(scanAudio())
|
||||
addAll(scanVideo())
|
||||
}.sortedByDescending { it.addedAt }
|
||||
}
|
||||
|
||||
fun hasPermission(): Boolean {
|
||||
val required = requiredPermissions()
|
||||
return required.any {
|
||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanAudio(): List<MediaEntry> {
|
||||
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
} else {
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
val projection = arrayOf(
|
||||
MediaStore.Audio.Media._ID,
|
||||
MediaStore.Audio.Media.TITLE,
|
||||
MediaStore.Audio.Media.ARTIST,
|
||||
MediaStore.Audio.Media.DURATION,
|
||||
MediaStore.Audio.Media.ALBUM_ID,
|
||||
MediaStore.Audio.Media.DATE_ADDED,
|
||||
)
|
||||
val entries = mutableListOf<MediaEntry>()
|
||||
context.contentResolver.query(
|
||||
collection,
|
||||
projection,
|
||||
"${MediaStore.Audio.Media.IS_MUSIC} != 0 OR ${MediaStore.Audio.Media.IS_PODCAST} != 0",
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||
val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
|
||||
val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
|
||||
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||
val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
|
||||
val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idCol)
|
||||
val artist = cursor.getString(artistCol).orEmpty()
|
||||
entries += MediaEntry(
|
||||
id = "local:audio:$id",
|
||||
source = SourceKind.LOCAL,
|
||||
kind = MediaKind.AUDIO,
|
||||
title = cursor.getString(titleCol) ?: "Sans titre",
|
||||
subtitle = if (artist.isBlank() || artist == "<unknown>") "Artiste inconnu" else artist,
|
||||
uri = ContentUris.withAppendedId(collection, id).toString(),
|
||||
artworkUri = albumArtUri(cursor.getLong(albumCol)).toString(),
|
||||
durationMs = cursor.getLong(durationCol),
|
||||
addedAt = cursor.getLong(addedCol) * 1000L,
|
||||
)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun scanVideo(): List<MediaEntry> {
|
||||
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
} else {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
val projection = arrayOf(
|
||||
MediaStore.Video.Media._ID,
|
||||
MediaStore.Video.Media.TITLE,
|
||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
||||
MediaStore.Video.Media.DURATION,
|
||||
MediaStore.Video.Media.DATE_ADDED,
|
||||
)
|
||||
val entries = mutableListOf<MediaEntry>()
|
||||
context.contentResolver.query(collection, projection, null, null, null)?.use { cursor ->
|
||||
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
|
||||
val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.TITLE)
|
||||
val bucketCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME)
|
||||
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
|
||||
val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idCol)
|
||||
val uri = ContentUris.withAppendedId(collection, id)
|
||||
entries += MediaEntry(
|
||||
id = "local:video:$id",
|
||||
source = SourceKind.LOCAL,
|
||||
kind = MediaKind.VIDEO,
|
||||
title = cursor.getString(titleCol) ?: "Sans titre",
|
||||
subtitle = cursor.getString(bucketCol) ?: "Vidéos",
|
||||
uri = uri.toString(),
|
||||
artworkUri = uri.toString(),
|
||||
durationMs = cursor.getLong(durationCol),
|
||||
addedAt = cursor.getLong(addedCol) * 1000L,
|
||||
)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun albumArtUri(albumId: Long): Uri =
|
||||
ContentUris.withAppendedId("content://media/external/audio/albumart".toUri(), albumId)
|
||||
|
||||
companion object {
|
||||
fun requiredPermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
arrayOf(
|
||||
android.Manifest.permission.READ_MEDIA_AUDIO,
|
||||
android.Manifest.permission.READ_MEDIA_VIDEO,
|
||||
)
|
||||
} else {
|
||||
arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.tuxgyver.cachette.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
enum class MediaKind { AUDIO, VIDEO }
|
||||
|
||||
/** Origine d'un média. Détermine ce qui est déjà hors connexion et ce qui doit être mis en cache. */
|
||||
enum class SourceKind(val label: String) {
|
||||
LOCAL("Appareil"),
|
||||
URL("Lien direct"),
|
||||
PODCAST("Podcast"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Une entrée de la bibliothèque, quelle que soit sa provenance.
|
||||
*
|
||||
* [id] est stable et sert aussi d'identifiant de téléchargement Media3 : c'est ce qui permet
|
||||
* de relier une entrée à son état de cache sans dupliquer l'information.
|
||||
*/
|
||||
@Serializable
|
||||
data class MediaEntry(
|
||||
val id: String,
|
||||
val source: SourceKind,
|
||||
val kind: MediaKind,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val uri: String,
|
||||
val artworkUri: String? = null,
|
||||
val durationMs: Long = 0L,
|
||||
val addedAt: Long = 0L,
|
||||
) {
|
||||
/** Un média local est déjà disponible hors connexion : il n'a rien à télécharger. */
|
||||
val isRemote: Boolean get() = source != SourceKind.LOCAL
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package fr.tuxgyver.cachette.offline
|
||||
|
||||
import android.app.Notification
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadService
|
||||
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||
import androidx.media3.exoplayer.workmanager.WorkManagerScheduler
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import fr.tuxgyver.cachette.CachetteApp
|
||||
import fr.tuxgyver.cachette.R
|
||||
|
||||
/**
|
||||
* Service de mise en cache. Il tourne en premier plan pendant les téléchargements et délègue la
|
||||
* reprise après coupure réseau ou redémarrage à WorkManager.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class CachetteDownloadService : DownloadService(
|
||||
NOTIFICATION_ID,
|
||||
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
||||
CHANNEL_ID,
|
||||
R.string.download_channel_name,
|
||||
0,
|
||||
) {
|
||||
private val notificationHelper: DownloadNotificationHelper by lazy {
|
||||
DownloadNotificationHelper(this, CHANNEL_ID)
|
||||
}
|
||||
|
||||
override fun getDownloadManager(): DownloadManager =
|
||||
(application as CachetteApp).container.offline.downloadManager
|
||||
|
||||
override fun getScheduler(): Scheduler = WorkManagerScheduler(this, WORK_NAME)
|
||||
|
||||
override fun getForegroundNotification(
|
||||
downloads: MutableList<Download>,
|
||||
notMetRequirements: Int,
|
||||
): Notification = notificationHelper.buildProgressNotification(
|
||||
this,
|
||||
R.drawable.ic_offline,
|
||||
null,
|
||||
null,
|
||||
downloads,
|
||||
notMetRequirements,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val NOTIFICATION_ID = 4201
|
||||
const val CHANNEL_ID = "cachette_downloads"
|
||||
const val WORK_NAME = "cachette-downloads"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package fr.tuxgyver.cachette.offline
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import androidx.media3.exoplayer.offline.DownloadService
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** État hors connexion d'une entrée, tel que l'UI en a besoin. */
|
||||
@OptIn(UnstableApi::class)
|
||||
data class OfflineState(
|
||||
val id: String,
|
||||
val state: Int,
|
||||
val percent: Float,
|
||||
val bytesDownloaded: Long,
|
||||
) {
|
||||
val isComplete: Boolean get() = state == Download.STATE_COMPLETED
|
||||
val isFailed: Boolean get() = state == Download.STATE_FAILED
|
||||
val isPending: Boolean
|
||||
get() = state == Download.STATE_QUEUED || state == Download.STATE_RESTARTING
|
||||
val isRunning: Boolean get() = state == Download.STATE_DOWNLOADING
|
||||
val isActive: Boolean get() = isPending || isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose l'état du cache hors connexion.
|
||||
*
|
||||
* L'index Media3 est la source de vérité ; on ne duplique rien. La progression des
|
||||
* téléchargements en cours n'étant pas notifiée, elle est relue par tic d'une seconde,
|
||||
* et seulement tant qu'un écran l'observe.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class DownloadTracker(
|
||||
private val context: Context,
|
||||
private val downloadManager: DownloadManager,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
private val indexed = MutableStateFlow<Map<String, OfflineState>>(emptyMap())
|
||||
|
||||
private val ticker = flow {
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
val states: StateFlow<Map<String, OfflineState>> =
|
||||
indexed.combine(ticker) { snapshot, _ ->
|
||||
snapshot + downloadManager.currentDownloads.associate { it.request.id to it.toOfflineState() }
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
private val listener = object : DownloadManager.Listener {
|
||||
override fun onDownloadChanged(manager: DownloadManager, download: Download, finalException: Exception?) {
|
||||
scope.launch { reload() }
|
||||
}
|
||||
|
||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||
scope.launch { reload() }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
downloadManager.addListener(listener)
|
||||
scope.launch { reload() }
|
||||
}
|
||||
|
||||
fun download(entry: MediaEntry) {
|
||||
if (!entry.isRemote) return
|
||||
val request = DownloadRequest.Builder(entry.id, entry.uri.toUri())
|
||||
.setData(entry.title.toByteArray())
|
||||
.build()
|
||||
DownloadService.sendAddDownload(context, CachetteDownloadService::class.java, request, false)
|
||||
}
|
||||
|
||||
fun remove(id: String) {
|
||||
DownloadService.sendRemoveDownload(context, CachetteDownloadService::class.java, id, false)
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
DownloadService.sendRemoveAllDownloads(context, CachetteDownloadService::class.java, false)
|
||||
}
|
||||
|
||||
private suspend fun reload() = withContext(Dispatchers.IO) {
|
||||
val snapshot = mutableMapOf<String, OfflineState>()
|
||||
runCatching {
|
||||
downloadManager.downloadIndex.getDownloads().use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
snapshot[download.request.id] = download.toOfflineState()
|
||||
}
|
||||
}
|
||||
}
|
||||
indexed.value = snapshot
|
||||
}
|
||||
|
||||
private fun Download.toOfflineState() = OfflineState(
|
||||
id = request.id,
|
||||
state = state,
|
||||
percent = if (percentDownloaded.isNaN()) 0f else percentDownloaded,
|
||||
bytesDownloaded = bytesDownloaded,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package fr.tuxgyver.cachette.offline
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.DatabaseProvider
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.scheduler.Requirements
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Le cache hors connexion et le gestionnaire de téléchargement, partagés par la lecture et l'UI.
|
||||
*
|
||||
* Le cache utilise [NoOpCacheEvictor] : il contient exactement ce que l'utilisateur a demandé à
|
||||
* garder, rien de plus, et rien n'est supprimé dans son dos. La lecture le lit sans y écrire
|
||||
* (`setCacheWriteDataSinkFactory(null)`), pour qu'un simple écoute en ligne ne le fasse pas gonfler.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class OfflineModule(private val context: Context) {
|
||||
|
||||
private val databaseProvider: DatabaseProvider by lazy { StandaloneDatabaseProvider(context) }
|
||||
|
||||
private val httpDataSourceFactory: DataSource.Factory by lazy {
|
||||
DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("Cachette/1.0 (Android)")
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
.setConnectTimeoutMs(20_000)
|
||||
.setReadTimeoutMs(30_000)
|
||||
}
|
||||
|
||||
val cache: Cache by lazy {
|
||||
val root = File(context.getExternalFilesDir(null) ?: context.filesDir, "offline-media")
|
||||
SimpleCache(root, NoOpCacheEvictor(), databaseProvider)
|
||||
}
|
||||
|
||||
val downloadManager: DownloadManager by lazy {
|
||||
DownloadManager(
|
||||
context,
|
||||
databaseProvider,
|
||||
cache,
|
||||
httpDataSourceFactory,
|
||||
Executors.newFixedThreadPool(MAX_PARALLEL_DOWNLOADS),
|
||||
).apply {
|
||||
maxParallelDownloads = MAX_PARALLEL_DOWNLOADS
|
||||
requirements = Requirements(Requirements.NETWORK)
|
||||
}
|
||||
}
|
||||
|
||||
/** Source de lecture : cache d'abord, réseau ou fichier local ensuite, jamais d'écriture. */
|
||||
fun playbackDataSourceFactory(): DataSource.Factory =
|
||||
CacheDataSource.Factory()
|
||||
.setCache(cache)
|
||||
.setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, httpDataSourceFactory))
|
||||
.setCacheWriteDataSinkFactory(null)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
|
||||
/** Octets réellement occupés par le contenu mis de côté. */
|
||||
fun cacheSizeBytes(): Long = cache.cacheSpace
|
||||
|
||||
private companion object {
|
||||
const val MAX_PARALLEL_DOWNLOADS = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package fr.tuxgyver.cachette.playback
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
|
||||
/**
|
||||
* Les `MediaItem` transmis par un contrôleur perdent leur URI en chemin : c'est le service qui la
|
||||
* rétablit à partir de l'identifiant. Ce mappage est donc le seul endroit où une entrée devient
|
||||
* lisible, des deux côtés de la session.
|
||||
*/
|
||||
fun MediaEntry.toMediaItem(): MediaItem = MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setUri(uri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(subtitle)
|
||||
.setArtworkUri(artworkUri?.toUri())
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
@@ -0,0 +1,86 @@
|
||||
package fr.tuxgyver.cachette.playback
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import fr.tuxgyver.cachette.CachetteApp
|
||||
import fr.tuxgyver.cachette.MainActivity
|
||||
|
||||
/** Lecture audio et vidéo en arrière-plan, avec notification et commandes système. */
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlaybackService : MediaSessionService() {
|
||||
|
||||
private var session: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val container = (application as CachetteApp).container
|
||||
|
||||
val player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(container.offline.playbackDataSourceFactory()))
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
val openApp = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
session = MediaSession.Builder(this, player)
|
||||
.setSessionActivity(openApp)
|
||||
.setCallback(ResolvingCallback())
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = session?.player
|
||||
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
session?.run {
|
||||
player.release()
|
||||
release()
|
||||
}
|
||||
session = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** Rend leur URI aux éléments reçus du contrôleur, à partir de la bibliothèque. */
|
||||
private inner class ResolvingCallback : MediaSession.Callback {
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
val library = (application as CachetteApp).container.library
|
||||
val resolved = mediaItems.mapTo(mutableListOf()) { item ->
|
||||
library.entry(item.mediaId)?.toMediaItem() ?: item
|
||||
}
|
||||
return Futures.immediateFuture(resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package fr.tuxgyver.cachette.playback
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
data class PlayerUiState(
|
||||
val mediaId: String? = null,
|
||||
val title: String = "",
|
||||
val subtitle: String = "",
|
||||
val artworkUri: String? = null,
|
||||
val kind: MediaKind = MediaKind.AUDIO,
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val durationMs: Long = 0L,
|
||||
val hasNext: Boolean = false,
|
||||
val hasPrevious: Boolean = false,
|
||||
) {
|
||||
val isIdle: Boolean get() = mediaId == null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pont entre l'interface et la session de lecture.
|
||||
*
|
||||
* La position n'est pas notifiée par le lecteur : elle est relue par tic, et seulement tant qu'un
|
||||
* écran l'observe.
|
||||
*/
|
||||
class PlayerController(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val resolve: (String) -> MediaEntry?,
|
||||
) {
|
||||
private var controller: MediaController? = null
|
||||
|
||||
private val _state = MutableStateFlow(PlayerUiState())
|
||||
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
|
||||
|
||||
val positionMs: StateFlow<Long> = flow {
|
||||
while (true) {
|
||||
emit(controller?.currentPosition ?: 0L)
|
||||
delay(500)
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(2_000), 0L)
|
||||
|
||||
/** Le lecteur brut, nécessaire pour rattacher une surface vidéo. */
|
||||
val player: Player? get() = controller
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) = syncState()
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
if (controller != null) return
|
||||
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val future = MediaController.Builder(context, token).buildAsync()
|
||||
future.addListener({
|
||||
controller = runCatching { future.get() }.getOrNull()?.also {
|
||||
it.addListener(listener)
|
||||
syncState()
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
|
||||
fun release() {
|
||||
controller?.removeListener(listener)
|
||||
controller?.release()
|
||||
controller = null
|
||||
}
|
||||
|
||||
/** Lance une file de lecture à partir de l'entrée choisie. */
|
||||
fun play(queue: List<MediaEntry>, startIndex: Int) {
|
||||
val controller = controller ?: return
|
||||
if (queue.isEmpty()) return
|
||||
controller.setMediaItems(queue.map { it.toMediaItem() }, startIndex, 0L)
|
||||
controller.prepare()
|
||||
controller.play()
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
val controller = controller ?: return
|
||||
if (controller.isPlaying) controller.pause() else controller.play()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
controller?.seekTo(positionMs)
|
||||
}
|
||||
|
||||
fun skipBy(deltaMs: Long) {
|
||||
val controller = controller ?: return
|
||||
controller.seekTo((controller.currentPosition + deltaMs).coerceAtLeast(0L))
|
||||
}
|
||||
|
||||
fun next() = controller?.seekToNextMediaItem() ?: Unit
|
||||
|
||||
fun previous() = controller?.seekToPreviousMediaItem() ?: Unit
|
||||
|
||||
fun stop() {
|
||||
controller?.stop()
|
||||
controller?.clearMediaItems()
|
||||
syncState()
|
||||
}
|
||||
|
||||
private fun syncState() {
|
||||
val controller = controller
|
||||
if (controller == null || controller.currentMediaItem == null) {
|
||||
_state.value = PlayerUiState()
|
||||
return
|
||||
}
|
||||
val item = controller.currentMediaItem!!
|
||||
val entry = resolve(item.mediaId)
|
||||
_state.value = PlayerUiState(
|
||||
mediaId = item.mediaId,
|
||||
title = entry?.title ?: item.mediaMetadata.title?.toString().orEmpty(),
|
||||
subtitle = entry?.subtitle ?: item.mediaMetadata.artist?.toString().orEmpty(),
|
||||
artworkUri = entry?.artworkUri ?: item.mediaMetadata.artworkUri?.toString(),
|
||||
kind = entry?.kind ?: MediaKind.AUDIO,
|
||||
isPlaying = controller.isPlaying,
|
||||
isBuffering = controller.playbackState == Player.STATE_BUFFERING,
|
||||
durationMs = controller.duration.takeIf { it > 0 } ?: entry?.durationMs ?: 0L,
|
||||
hasNext = controller.hasNextMediaItem(),
|
||||
hasPrevious = controller.hasPreviousMediaItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package fr.tuxgyver.cachette.sources
|
||||
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Recherche dans archive.org.
|
||||
*
|
||||
* La recherche ne renvoie que des identifiants ; le fichier réellement lisible n'est déterminé
|
||||
* qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de
|
||||
* trente éléments dont vingt-neuf seront ignorés.
|
||||
*/
|
||||
class ArchiveOrgCatalog : Catalog {
|
||||
|
||||
override val id = CatalogId.ARCHIVE
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val q = URLEncoder.encode("($query) AND mediatype:(audio OR movies)", "UTF-8")
|
||||
val url = "https://archive.org/advancedsearch.php?q=$q" +
|
||||
"&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype" +
|
||||
"&rows=30&page=1&output=json"
|
||||
|
||||
val body = fetch(url)
|
||||
val docs = json.parseToJsonElement(body).jsonObject["response"]
|
||||
?.jsonObject?.get("docs") as? JsonArray
|
||||
?: throw IllegalStateException("Réponse inattendue d'archive.org")
|
||||
|
||||
docs.mapNotNull { element ->
|
||||
val doc = element as? JsonObject ?: return@mapNotNull null
|
||||
val identifier = doc.text("identifier") ?: return@mapNotNull null
|
||||
CatalogItem(
|
||||
id = identifier,
|
||||
title = doc.text("title") ?: identifier,
|
||||
subtitle = doc.text("creator") ?: "Internet Archive",
|
||||
artworkUri = "https://archive.org/services/img/$identifier",
|
||||
hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
|
||||
val files = metadata["files"] as? JsonArray ?: JsonArray(emptyList())
|
||||
val wantsVideo = item.hint == "Vidéo"
|
||||
|
||||
val playable = files.mapNotNull { it as? JsonObject }
|
||||
.firstOrNull { file ->
|
||||
val name = file.text("name").orEmpty().lowercase()
|
||||
if (wantsVideo) name.endsWith(".mp4") else name.endsWith(".mp3")
|
||||
}
|
||||
?: throw IllegalStateException("Aucun fichier lisible pour « ${item.title} »")
|
||||
|
||||
val name = playable.text("name")!!
|
||||
val seconds = playable.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
||||
|
||||
listOf(
|
||||
MediaEntry(
|
||||
id = stableId("ia:", "${item.id}/$name"),
|
||||
source = SourceKind.URL,
|
||||
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
uri = "https://archive.org/download/${item.id}/${URLEncoder.encode(name, "UTF-8").replace("+", "%20")}",
|
||||
artworkUri = item.artworkUri,
|
||||
durationMs = seconds * 1000,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetch(url: String): String {
|
||||
val connection = openConnection(url)
|
||||
return try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("archive.org a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** archive.org renvoie tantôt une chaîne, tantôt un tableau : on prend la première valeur utile. */
|
||||
internal fun JsonObject.text(key: String): String? = when (val value = this[key]) {
|
||||
is JsonPrimitive -> value.content.takeIf { it.isNotBlank() }
|
||||
is JsonArray -> value.firstOrNull()?.let { (it as? JsonPrimitive)?.content }?.takeIf { it.isNotBlank() }
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package fr.tuxgyver.cachette.sources
|
||||
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
|
||||
/** Un résultat de recherche dans un catalogue, avant qu'on sache quel fichier exact il désigne. */
|
||||
data class CatalogItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val artworkUri: String? = null,
|
||||
val hint: String = "",
|
||||
)
|
||||
|
||||
/** Catalogue public interrogeable sans compte ni clé d'API. */
|
||||
interface Catalog {
|
||||
val id: CatalogId
|
||||
|
||||
suspend fun search(query: String): Result<List<CatalogItem>>
|
||||
|
||||
/**
|
||||
* Transforme un résultat en entrées jouables. Un livre audio en rend plusieurs, un morceau une
|
||||
* seule : la résolution est faite ici, à la sélection, et pas pendant la recherche.
|
||||
*/
|
||||
suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>>
|
||||
}
|
||||
|
||||
enum class CatalogId(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val license: String,
|
||||
val suggestion: String,
|
||||
) {
|
||||
ARCHIVE(
|
||||
title = "Internet Archive",
|
||||
description = "Musique, concerts et films versés au domaine public ou sous licence libre.",
|
||||
license = "Domaine public / Creative Commons",
|
||||
suggestion = "Grateful Dead",
|
||||
),
|
||||
LIBRIVOX(
|
||||
title = "LibriVox",
|
||||
description = "Livres audio lus par des bénévoles, tous dans le domaine public.",
|
||||
license = "Domaine public",
|
||||
suggestion = "Jules Verne",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package fr.tuxgyver.cachette.sources
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal fun stableId(prefix: String, value: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-1").digest(value.toByteArray())
|
||||
return prefix + digest.joinToString("") { "%02x".format(it) }.take(16)
|
||||
}
|
||||
|
||||
internal fun openConnection(url: String, method: String = "GET"): HttpURLConnection =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 20_000
|
||||
setRequestProperty("User-Agent", "Cachette/1.0 (Android)")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package fr.tuxgyver.cachette.sources
|
||||
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Recherche dans LibriVox.
|
||||
*
|
||||
* Chaque livre publie un flux RSS : la résolution réutilise donc l'import de podcast plutôt que
|
||||
* de redécrire un parcours de chapitres.
|
||||
*/
|
||||
class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
||||
|
||||
override val id = CatalogId.LIBRIVOX
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25" +
|
||||
"&title=${URLEncoder.encode(query, "UTF-8")}"
|
||||
|
||||
val connection = openConnection(url)
|
||||
val body = try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.bufferedReader().readText()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray
|
||||
?: throw IllegalStateException("Aucun livre trouvé")
|
||||
|
||||
books.mapNotNull { element ->
|
||||
val book = element as? JsonObject ?: return@mapNotNull null
|
||||
val feed = book.text("url_rss") ?: return@mapNotNull null
|
||||
CatalogItem(
|
||||
id = feed,
|
||||
title = book.text("title") ?: "Livre audio",
|
||||
subtitle = book.authors(),
|
||||
hint = "Livre audio",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
|
||||
rss.import(item.id).map { it.episodes }
|
||||
|
||||
private fun JsonObject.authors(): String {
|
||||
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
|
||||
return authors.mapNotNull { it as? JsonObject }
|
||||
.joinToString(", ") { author ->
|
||||
listOfNotNull(author.text("first_name"), author.text("last_name")).joinToString(" ")
|
||||
}
|
||||
.ifBlank { "LibriVox" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package fr.tuxgyver.cachette.sources
|
||||
|
||||
import android.util.Xml
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
data class PodcastFeed(
|
||||
val title: String,
|
||||
val artworkUri: String?,
|
||||
val episodes: List<MediaEntry>,
|
||||
)
|
||||
|
||||
/** Importe un flux RSS de podcast : chaque `<enclosure>` devient une entrée téléchargeable. */
|
||||
class RssImporter {
|
||||
|
||||
suspend fun import(feedUrl: String): Result<PodcastFeed> = withContext(Dispatchers.IO) {
|
||||
val url = feedUrl.trim()
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://"))
|
||||
}
|
||||
runCatching {
|
||||
val connection = openConnection(url)
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
|
||||
}
|
||||
parse(connection.inputStream.bufferedReader().readText())
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(xml: String): PodcastFeed {
|
||||
val parser = Xml.newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
setInput(xml.reader())
|
||||
}
|
||||
|
||||
var feedTitle = ""
|
||||
var feedImage: String? = null
|
||||
val episodes = mutableListOf<MediaEntry>()
|
||||
|
||||
var item: Draft? = null
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when (parser.name) {
|
||||
"item", "entry" -> item = Draft()
|
||||
"title" -> {
|
||||
val value = parser.textOrEmpty()
|
||||
if (item != null) item.title = value else if (feedTitle.isEmpty()) feedTitle = value
|
||||
}
|
||||
"enclosure" -> if (item != null) {
|
||||
item.url = parser.getAttributeValue(null, "url")
|
||||
item.type = parser.getAttributeValue(null, "type").orEmpty()
|
||||
}
|
||||
"itunes:image" -> {
|
||||
val href = parser.getAttributeValue(null, "href")
|
||||
if (item != null) item.image = href else if (feedImage == null) feedImage = href
|
||||
}
|
||||
"itunes:duration" -> if (item != null) item.duration = parser.textOrEmpty()
|
||||
"pubDate" -> if (item != null) item.pubDate = parser.textOrEmpty()
|
||||
"url" -> if (item == null && feedImage == null) feedImage = parser.textOrEmpty()
|
||||
}
|
||||
|
||||
XmlPullParser.END_TAG -> if (parser.name == "item" || parser.name == "entry") {
|
||||
item?.toEntry(feedTitle, feedImage)?.let(episodes::add)
|
||||
item = null
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
|
||||
if (episodes.isEmpty()) {
|
||||
throw IllegalStateException("Aucun épisode téléchargeable trouvé dans ce flux")
|
||||
}
|
||||
return PodcastFeed(
|
||||
title = feedTitle.ifEmpty { "Podcast" },
|
||||
artworkUri = feedImage,
|
||||
episodes = episodes.take(MAX_EPISODES),
|
||||
)
|
||||
}
|
||||
|
||||
private class Draft {
|
||||
var title: String = ""
|
||||
var url: String? = null
|
||||
var type: String = ""
|
||||
var image: String? = null
|
||||
var duration: String = ""
|
||||
var pubDate: String = ""
|
||||
|
||||
fun toEntry(feedTitle: String, feedImage: String?): MediaEntry? {
|
||||
val mediaUrl = url?.takeIf { it.isNotBlank() } ?: return null
|
||||
return MediaEntry(
|
||||
id = stableId("pod:", mediaUrl),
|
||||
source = SourceKind.PODCAST,
|
||||
kind = if (type.startsWith("video/")) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||
title = title.ifBlank { "Épisode" },
|
||||
subtitle = feedTitle.ifBlank { "Podcast" },
|
||||
uri = mediaUrl,
|
||||
artworkUri = image ?: feedImage,
|
||||
durationMs = parseDuration(duration),
|
||||
addedAt = parseDate(pubDate),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_EPISODES = 100
|
||||
|
||||
fun XmlPullParser.textOrEmpty(): String =
|
||||
runCatching { nextText().trim() }.getOrDefault("")
|
||||
|
||||
/** `1:02:03`, `12:34` ou un nombre de secondes. */
|
||||
fun parseDuration(raw: String): Long {
|
||||
if (raw.isBlank()) return 0L
|
||||
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
|
||||
return when (parts.size) {
|
||||
1 -> parts[0] * 1000
|
||||
2 -> (parts[0] * 60 + parts[1]) * 1000
|
||||
3 -> (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
|
||||
fun parseDate(raw: String): Long {
|
||||
if (raw.isBlank()) return System.currentTimeMillis()
|
||||
val formats = listOf("EEE, dd MMM yyyy HH:mm:ss Z", "EEE, dd MMM yyyy HH:mm:ss zzz")
|
||||
for (pattern in formats) {
|
||||
runCatching { SimpleDateFormat(pattern, Locale.US).parse(raw)?.time }
|
||||
.getOrNull()?.let { return it }
|
||||
}
|
||||
return System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package fr.tuxgyver.cachette.sources
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Importe un lien direct vers un fichier média (mp3, m4a, mp4, webm…). */
|
||||
class UrlImporter {
|
||||
|
||||
suspend fun import(rawUrl: String): Result<MediaEntry> = withContext(Dispatchers.IO) {
|
||||
val url = rawUrl.trim()
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://"))
|
||||
}
|
||||
runCatching {
|
||||
val connection = openConnection(url, "HEAD")
|
||||
val contentType = try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Le serveur a répondu ${connection.responseCode}")
|
||||
}
|
||||
connection.contentType.orEmpty()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
val kind = kindOf(contentType, url)
|
||||
?: throw IllegalStateException("Ce lien ne pointe pas vers un fichier audio ou vidéo")
|
||||
|
||||
val name = url.toUri().lastPathSegment
|
||||
?.substringBeforeLast('.')
|
||||
?.replace(Regex("[_-]+"), " ")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Média importé"
|
||||
|
||||
MediaEntry(
|
||||
id = stableId("url:", url),
|
||||
source = SourceKind.URL,
|
||||
kind = kind,
|
||||
title = name,
|
||||
subtitle = url.toUri().host ?: "Lien direct",
|
||||
uri = url,
|
||||
addedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun kindOf(contentType: String, url: String): MediaKind? {
|
||||
val mime = contentType.substringBefore(';').trim().lowercase()
|
||||
return when {
|
||||
mime.startsWith("video/") -> MediaKind.VIDEO
|
||||
mime.startsWith("audio/") -> MediaKind.AUDIO
|
||||
mime == "application/vnd.apple.mpegurl" || mime == "application/x-mpegurl" -> MediaKind.VIDEO
|
||||
else -> when (url.substringAfterLast('.', "").lowercase().substringBefore('?')) {
|
||||
"mp3", "m4a", "aac", "ogg", "opus", "flac", "wav" -> MediaKind.AUDIO
|
||||
"mp4", "mkv", "webm", "m4v", "mov" -> MediaKind.VIDEO
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package fr.tuxgyver.cachette.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudDownload
|
||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||
import androidx.compose.material.icons.rounded.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
import fr.tuxgyver.cachette.ui.browse.CatalogBrowseScreen
|
||||
import fr.tuxgyver.cachette.ui.browse.SourceBrowseScreen
|
||||
import fr.tuxgyver.cachette.ui.library.LibraryScreen
|
||||
import fr.tuxgyver.cachette.ui.offline.DownloadsScreen
|
||||
import fr.tuxgyver.cachette.ui.player.MiniPlayer
|
||||
import fr.tuxgyver.cachette.ui.player.NowPlayingScreen
|
||||
import fr.tuxgyver.cachette.ui.sources.SourcesScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class Tab(val route: String, val label: String, val icon: ImageVector) {
|
||||
LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic),
|
||||
OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload),
|
||||
SOURCES("sources", "Sources", Icons.Rounded.Tune),
|
||||
}
|
||||
|
||||
private const val BROWSE_ROUTE = "browse/{source}"
|
||||
private const val CATALOG_ROUTE = "catalog/{catalog}"
|
||||
|
||||
@Composable
|
||||
fun CachetteRoot(container: AppContainer) {
|
||||
val navController = rememberNavController()
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val playerState by container.player.state.collectAsStateWithLifecycle()
|
||||
val position by container.player.positionMs.collectAsStateWithLifecycle()
|
||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||
|
||||
val snackbarHost = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val play: (List<MediaEntry>, Int) -> Unit = { queue, index ->
|
||||
container.player.play(queue, index)
|
||||
expanded = true
|
||||
}
|
||||
val notify: (String) -> Unit = { message -> scope.launch { snackbarHost.showSnackbar(message) } }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHost) },
|
||||
bottomBar = {
|
||||
Column {
|
||||
if (!playerState.isIdle) {
|
||||
MiniPlayer(
|
||||
state = playerState,
|
||||
positionMs = position,
|
||||
onExpand = { expanded = true },
|
||||
onTogglePlay = container.player::togglePlayPause,
|
||||
onNext = container.player::next,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
}
|
||||
NavigationBar {
|
||||
Tab.entries.forEach { tab ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == tab.route,
|
||||
onClick = { navController.switchTab(tab.route) },
|
||||
icon = { Icon(tab.icon, contentDescription = null) },
|
||||
label = { Text(tab.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(navController = navController, startDestination = Tab.LIBRARY.route) {
|
||||
composable(Tab.LIBRARY.route) {
|
||||
LibraryScreen(
|
||||
container = container,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onPlay = play,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Tab.OFFLINE.route) {
|
||||
DownloadsScreen(
|
||||
container = container,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onPlay = play,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Tab.SOURCES.route) {
|
||||
SourcesScreen(
|
||||
container = container,
|
||||
contentPadding = padding,
|
||||
onMessage = notify,
|
||||
onBrowseSource = { navController.navigate("browse/${it.name}") },
|
||||
onBrowseCatalog = { navController.navigate("catalog/${it.name}") },
|
||||
)
|
||||
}
|
||||
|
||||
composable(BROWSE_ROUTE) { entry ->
|
||||
val source = SourceKind.valueOf(entry.arguments?.getString("source") ?: SourceKind.LOCAL.name)
|
||||
SourceBrowseScreen(
|
||||
container = container,
|
||||
source = source,
|
||||
currentMediaId = playerState.mediaId,
|
||||
onBack = navController::popBackStack,
|
||||
onPlay = play,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
|
||||
composable(CATALOG_ROUTE) { entry ->
|
||||
val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name)
|
||||
CatalogBrowseScreen(
|
||||
container = container,
|
||||
catalogId = catalog,
|
||||
onBack = navController::popBackStack,
|
||||
onMessage = notify,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded && !playerState.isIdle,
|
||||
enter = slideInVertically { it },
|
||||
exit = slideOutVertically { it },
|
||||
) {
|
||||
val entry = playerState.mediaId?.let(container.library::entry)
|
||||
NowPlayingScreen(
|
||||
state = playerState,
|
||||
positionMs = position,
|
||||
entry = entry,
|
||||
offline = playerState.mediaId?.let { offlineStates[it] },
|
||||
player = container.player.player,
|
||||
onCollapse = { expanded = false },
|
||||
onTogglePlay = container.player::togglePlayPause,
|
||||
onSeek = container.player::seekTo,
|
||||
onSkipBy = container.player::skipBy,
|
||||
onNext = container.player::next,
|
||||
onPrevious = container.player::previous,
|
||||
onDownload = { entry?.let(container.downloads::download) },
|
||||
onRemoveOffline = { playerState.mediaId?.let(container.downloads::remove) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = expanded) { expanded = false }
|
||||
}
|
||||
|
||||
/** Un onglet se rejoint sans empiler : on revient là où on l'avait laissé. */
|
||||
private fun NavHostController.switchTab(route: String) {
|
||||
navigate(route) {
|
||||
popUpTo(graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package fr.tuxgyver.cachette.ui.browse
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
import fr.tuxgyver.cachette.sources.CatalogItem
|
||||
import fr.tuxgyver.cachette.ui.components.Artwork
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Recherche dans un catalogue libre de droits, puis ajout de la sélection à la bibliothèque.
|
||||
*
|
||||
* L'ajout déclenche aussi la mise en cache : on ne parcourt pas un catalogue pour regarder
|
||||
* passer les titres, mais pour les emporter.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CatalogBrowseScreen(
|
||||
container: AppContainer,
|
||||
catalogId: CatalogId,
|
||||
onBack: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val catalog = container.catalogs.getValue(catalogId)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var query by rememberSaveable { mutableStateOf(catalogId.suggestion) }
|
||||
var results by remember { mutableStateOf<List<CatalogItem>>(emptyList()) }
|
||||
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun runSearch() {
|
||||
if (query.isBlank()) return
|
||||
searching = true
|
||||
error = null
|
||||
scope.launch {
|
||||
catalog.search(query).fold(
|
||||
onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" },
|
||||
onFailure = { error = it.message ?: "Recherche impossible" },
|
||||
)
|
||||
searching = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(catalogId) { runSearch() }
|
||||
|
||||
// La barre de titre occupe déjà l'espace haut du Scaffold : la liste ne reprend que le bas.
|
||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(catalogId.title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = catalogId.license,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Rechercher dans ${catalogId.title}") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { runSearch() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
|
||||
when {
|
||||
searching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
error != null -> Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = error!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
else -> LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(results, key = { it.id }) { item ->
|
||||
CatalogRow(
|
||||
item = item,
|
||||
selected = item.id in selection,
|
||||
onToggle = {
|
||||
selection = if (item.id in selection) selection - item.id
|
||||
else selection + item.id
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.size(88.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SelectionBar(
|
||||
count = selection.size,
|
||||
actionLabel = "Ajouter et garder",
|
||||
busy = adding,
|
||||
onClear = { selection = emptySet() },
|
||||
onConfirm = {
|
||||
adding = true
|
||||
scope.launch {
|
||||
val chosen = results.filter { it.id in selection }
|
||||
var added = 0
|
||||
var failed = 0
|
||||
chosen.forEach { item ->
|
||||
catalog.resolve(item).fold(
|
||||
onSuccess = { entries ->
|
||||
container.imported.add(entries)
|
||||
entries.forEach(container.downloads::download)
|
||||
added += entries.size
|
||||
},
|
||||
onFailure = { failed++ },
|
||||
)
|
||||
}
|
||||
adding = false
|
||||
selection = emptySet()
|
||||
onMessage(
|
||||
buildString {
|
||||
append("$added élément(s) ajoutés et mis en cache")
|
||||
if (failed > 0) append(" · $failed indisponible(s)")
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(contentPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogRow(
|
||||
item: CatalogItem,
|
||||
selected: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (selected) MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
) {
|
||||
Artwork(
|
||||
uri = item.artworkUri,
|
||||
title = item.title,
|
||||
kind = if (item.hint == "Vidéo") MediaKind.VIDEO else MediaKind.AUDIO,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = listOf(item.subtitle, item.hint).filter { it.isNotBlank() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Checkbox(checked = selected, onCheckedChange = { onToggle() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package fr.tuxgyver.cachette.ui.browse
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Barre d'action des écrans de sélection : combien d'éléments, et que faire de la sélection. */
|
||||
@Composable
|
||||
fun SelectionBar(
|
||||
count: Int,
|
||||
actionLabel: String,
|
||||
busy: Boolean,
|
||||
onClear: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = count > 0,
|
||||
enter = slideInVertically { it },
|
||||
exit = slideOutVertically { it },
|
||||
modifier = modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$count sélectionné(s)",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onClear, enabled = !busy) { Text("Effacer") }
|
||||
Button(onClick = onConfirm, enabled = !busy) {
|
||||
if (busy) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Rounded.Download, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package fr.tuxgyver.cachette.ui.browse
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import fr.tuxgyver.cachette.ui.components.MediaRow
|
||||
|
||||
/**
|
||||
* Parcours d'une source déjà présente dans la bibliothèque : on y choisit, un par un ou en lot,
|
||||
* ce qui doit être gardé sur l'appareil.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SourceBrowseScreen(
|
||||
container: AppContainer,
|
||||
source: SourceKind,
|
||||
currentMediaId: String?,
|
||||
onBack: () -> Unit,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||
|
||||
val ofSource = remember(entries, source) { entries.filter { it.source == source } }
|
||||
val collections = remember(ofSource) { ofSource.map { it.subtitle }.distinct().sorted() }
|
||||
|
||||
var collection by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selection by remember { mutableStateOf(emptySet<String>()) }
|
||||
|
||||
val visible = remember(ofSource, collection) {
|
||||
if (collection == null) ofSource else ofSource.filter { it.subtitle == collection }
|
||||
}
|
||||
val selectable = remember(visible, offlineStates) {
|
||||
visible.filter { it.isRemote && offlineStates[it.id]?.isComplete != true }
|
||||
}
|
||||
|
||||
// La barre de titre occupe déjà l'espace haut du Scaffold : la liste ne reprend que le bas.
|
||||
val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding())
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(source.label) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (selectable.isNotEmpty()) {
|
||||
TextButton(onClick = {
|
||||
selection = if (selection.size == selectable.size) emptySet()
|
||||
else selectable.mapTo(mutableSetOf()) { it.id }
|
||||
}) {
|
||||
Text(if (selection.size == selectable.size) "Aucun" else "Tout")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (collections.size > 1) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = collection == null,
|
||||
onClick = { collection = null },
|
||||
label = { Text("Tout") },
|
||||
)
|
||||
collections.forEach { name ->
|
||||
FilterChip(
|
||||
selected = collection == name,
|
||||
onClick = { collection = name },
|
||||
label = { Text(name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Aucun contenu pour cette source. Ajoutez-en depuis l'écran Sources.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
items(visible, key = { it.id }) { entry ->
|
||||
val canSelect = entry.isRemote && offlineStates[entry.id]?.isComplete != true
|
||||
MediaRow(
|
||||
entry = entry,
|
||||
offline = offlineStates[entry.id],
|
||||
isCurrent = entry.id == currentMediaId,
|
||||
selected = if (canSelect) entry.id in selection else null,
|
||||
onClick = {
|
||||
if (canSelect) {
|
||||
selection = if (entry.id in selection) selection - entry.id
|
||||
else selection + entry.id
|
||||
} else {
|
||||
onPlay(visible, visible.indexOf(entry))
|
||||
}
|
||||
},
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.size(88.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SelectionBar(
|
||||
count = selection.size,
|
||||
actionLabel = "Télécharger",
|
||||
busy = false,
|
||||
onClear = { selection = emptySet() },
|
||||
onConfirm = {
|
||||
visible.filter { it.id in selection }.forEach(container.downloads::download)
|
||||
selection = emptySet()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(contentPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package fr.tuxgyver.cachette.ui.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.util.Size
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.MusicNote
|
||||
import androidx.compose.material.icons.rounded.Videocam
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import coil.compose.AsyncImage
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Vignette d'un média.
|
||||
*
|
||||
* Trois cas, un seul point d'entrée : image distante (Coil), vignette MediaStore (ContentResolver),
|
||||
* et à défaut un dégradé déterministe dérivé du titre — jamais de case vide.
|
||||
*/
|
||||
@Composable
|
||||
fun Artwork(
|
||||
uri: String?,
|
||||
title: String,
|
||||
kind: MediaKind,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val gradient = remember(title) { gradientFor(title) }
|
||||
var thumbnail by remember(uri) { mutableStateOf<Bitmap?>(null) }
|
||||
var failed by remember(uri) { mutableStateOf(false) }
|
||||
|
||||
val needsThumbnail = uri != null && uri.startsWith("content://") && kind == MediaKind.VIDEO
|
||||
|
||||
LaunchedEffect(uri, needsThumbnail) {
|
||||
if (!needsThumbnail || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@LaunchedEffect
|
||||
thumbnail = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.contentResolver.loadThumbnail(uri!!.toUri(), Size(320, 320), null)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Brush.linearGradient(gradient)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val bitmap = thumbnail
|
||||
when {
|
||||
bitmap != null -> Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
uri != null && !needsThumbnail && !failed -> AsyncImage(
|
||||
model = uri,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = { failed = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
else -> Icon(
|
||||
imageVector = if (kind == MediaKind.VIDEO) Icons.Rounded.Videocam else Icons.Rounded.MusicNote,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dégradé stable tiré du titre : deux fois le même média, deux fois la même couleur.
|
||||
* Les paires sont choisies dans la famille chromatique de l'application, pour que les vignettes
|
||||
* sans pochette restent variées sans jurer avec le reste de l'interface.
|
||||
*/
|
||||
private val PLACEHOLDER_GRADIENTS = listOf(
|
||||
Color(0xFF6B4EFF) to Color(0xFF3FD8C0),
|
||||
Color(0xFF8E5BFF) to Color(0xFFE0629B),
|
||||
Color(0xFF3A7BFF) to Color(0xFF6B4EFF),
|
||||
Color(0xFF11A894) to Color(0xFF3A7BFF),
|
||||
Color(0xFFE0629B) to Color(0xFFFF9A62),
|
||||
Color(0xFF4E3D8C) to Color(0xFF11A894),
|
||||
)
|
||||
|
||||
private fun gradientFor(seed: String): List<Color> {
|
||||
val index = ((seed.hashCode() % PLACEHOLDER_GRADIENTS.size) + PLACEHOLDER_GRADIENTS.size) %
|
||||
PLACEHOLDER_GRADIENTS.size
|
||||
val (start, end) = PLACEHOLDER_GRADIENTS[index]
|
||||
return listOf(start, end)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package fr.tuxgyver.cachette.ui.components
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
fun formatDuration(millis: Long): String {
|
||||
if (millis <= 0) return "--:--"
|
||||
val totalSeconds = millis / 1000
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return if (hours > 0) {
|
||||
String.format(Locale.FRANCE, "%d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
String.format(Locale.FRANCE, "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String = when {
|
||||
bytes <= 0 -> "0 Mo"
|
||||
bytes < 1_000_000 -> String.format(Locale.FRANCE, "%.0f Ko", bytes / 1_000.0)
|
||||
bytes < 1_000_000_000 -> String.format(Locale.FRANCE, "%.1f Mo", bytes / 1_000_000.0)
|
||||
else -> String.format(Locale.FRANCE, "%.2f Go", bytes / 1_000_000_000.0)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package fr.tuxgyver.cachette.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.GraphicEq
|
||||
import androidx.compose.material.icons.rounded.Smartphone
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.offline.OfflineState
|
||||
|
||||
@Composable
|
||||
fun MediaRow(
|
||||
entry: MediaEntry,
|
||||
offline: OfflineState?,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onRemoveOffline: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/** Non nul en mode sélection : la ligne porte alors une case à cocher au lieu de son action. */
|
||||
selected: Boolean? = null,
|
||||
) {
|
||||
val background by animateColorAsState(
|
||||
targetValue = when {
|
||||
selected == true -> MaterialTheme.colorScheme.secondaryContainer
|
||||
isCurrent -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
},
|
||||
label = "row-background",
|
||||
)
|
||||
|
||||
Surface(
|
||||
color = background,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
) {
|
||||
Artwork(
|
||||
uri = entry.artworkUri,
|
||||
title = entry.title,
|
||||
kind = entry.kind,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = entry.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Text(
|
||||
text = " · ${formatDuration(entry.durationMs)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
if (isCurrent && selected == null) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.GraphicEq,
|
||||
contentDescription = "En cours de lecture",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (selected != null) {
|
||||
Checkbox(checked = selected, onCheckedChange = { onClick() })
|
||||
} else {
|
||||
OfflineAction(
|
||||
entry = entry,
|
||||
offline = offline,
|
||||
onDownload = onDownload,
|
||||
onRemoveOffline = onRemoveOffline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineAction(
|
||||
entry: MediaEntry,
|
||||
offline: OfflineState?,
|
||||
onDownload: () -> Unit,
|
||||
onRemoveOffline: () -> Unit,
|
||||
) {
|
||||
when {
|
||||
!entry.isRemote -> Icon(
|
||||
imageVector = Icons.Rounded.Smartphone,
|
||||
contentDescription = "Déjà sur l'appareil",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp).size(20.dp),
|
||||
)
|
||||
|
||||
offline?.isComplete == true -> IconButton(onClick = onRemoveOffline) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CheckCircle,
|
||||
contentDescription = "Disponible hors connexion — appuyer pour libérer",
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
offline?.isActive == true -> Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
if (offline.isRunning && offline.percent > 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = { offline.percent / 100f },
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
offline?.isFailed == true -> IconButton(onClick = onDownload) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ErrorOutline,
|
||||
contentDescription = "Échec — réessayer",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
else -> IconButton(onClick = onDownload) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Download,
|
||||
contentDescription = "Mettre de côté pour plus tard",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package fr.tuxgyver.cachette.ui.library
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.LibraryMusic
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.library.LocalMediaScanner
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.offline.OfflineState
|
||||
import fr.tuxgyver.cachette.ui.components.MediaRow
|
||||
|
||||
private enum class LibraryFilter(val label: String) {
|
||||
ALL("Tout"),
|
||||
AUDIO("Musique"),
|
||||
VIDEO("Vidéo"),
|
||||
OFFLINE("Hors ligne"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
container: AppContainer,
|
||||
currentMediaId: String?,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||
val scanning by container.library.scanning.collectAsStateWithLifecycle()
|
||||
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var filter by rememberSaveable { mutableStateOf(LibraryFilter.ALL) }
|
||||
var hasPermission by remember { mutableStateOf(container.library.hasLocalPermission()) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted ->
|
||||
hasPermission = granted.values.any { it }
|
||||
if (hasPermission) container.library.refreshLocal()
|
||||
}
|
||||
|
||||
LaunchedEffect(hasPermission) {
|
||||
if (hasPermission) container.library.refreshLocal()
|
||||
}
|
||||
|
||||
val visible = remember(entries, offlineStates, query, filter) {
|
||||
entries.filter { entry ->
|
||||
matchesQuery(entry, query) && matchesFilter(entry, filter, offlineStates[entry.id])
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Column {
|
||||
Text(
|
||||
text = "Bibliothèque",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Text(
|
||||
text = "${entries.size} éléments · ${offlineStates.values.count { it.isComplete }} mis de côté",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Rechercher un titre, un artiste…") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
LibraryFilter.entries.forEach { option ->
|
||||
FilterChip(
|
||||
selected = filter == option,
|
||||
onClick = { filter = option },
|
||||
label = { Text(option.label) },
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = FilterChipDefaults.filterChipColors(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
|
||||
if (scanning) {
|
||||
item {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
item {
|
||||
PermissionCard(
|
||||
onGrant = { permissionLauncher.launch(LocalMediaScanner.requiredPermissions()) }
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
item { EmptyState(hasAnything = entries.isNotEmpty()) }
|
||||
}
|
||||
|
||||
items(visible, key = { it.id }) { entry ->
|
||||
MediaRow(
|
||||
entry = entry,
|
||||
offline = offlineStates[entry.id],
|
||||
isCurrent = entry.id == currentMediaId,
|
||||
onClick = { onPlay(visible, visible.indexOf(entry)) },
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesQuery(entry: MediaEntry, query: String): Boolean {
|
||||
if (query.isBlank()) return true
|
||||
val needle = query.trim().lowercase()
|
||||
return entry.title.lowercase().contains(needle) || entry.subtitle.lowercase().contains(needle)
|
||||
}
|
||||
|
||||
private fun matchesFilter(entry: MediaEntry, filter: LibraryFilter, offline: OfflineState?): Boolean =
|
||||
when (filter) {
|
||||
LibraryFilter.ALL -> true
|
||||
LibraryFilter.AUDIO -> entry.kind == MediaKind.AUDIO
|
||||
LibraryFilter.VIDEO -> entry.kind == MediaKind.VIDEO
|
||||
LibraryFilter.OFFLINE -> !entry.isRemote || offline?.isComplete == true
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionCard(onGrant: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text("Accéder à vos médias", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(
|
||||
"Cachette lit la musique et les vidéos déjà présentes sur l'appareil. Rien n'est envoyé ailleurs.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.size(14.dp))
|
||||
Button(onClick = onGrant) { Text("Autoriser") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(hasAnything: Boolean) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 56.dp),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.LibraryMusic,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
text = if (hasAnything) "Rien ne correspond à cette recherche"
|
||||
else "Votre bibliothèque est vide",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!hasAnything) {
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(
|
||||
text = "Ajoutez une source pour commencer à remplir votre cachette.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package fr.tuxgyver.cachette.ui.offline
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.ui.components.MediaRow
|
||||
import fr.tuxgyver.cachette.ui.components.formatBytes
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen(
|
||||
container: AppContainer,
|
||||
currentMediaId: String?,
|
||||
onPlay: (List<MediaEntry>, Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
|
||||
var confirmClear by remember { mutableStateOf(false) }
|
||||
var cacheSize by remember { mutableLongStateOf(0L) }
|
||||
|
||||
LaunchedEffect(offlineStates) {
|
||||
cacheSize = withContext(Dispatchers.IO) { container.offline.cacheSizeBytes() }
|
||||
}
|
||||
|
||||
val cached = remember(entries, offlineStates) {
|
||||
entries.filter { offlineStates[it.id] != null }
|
||||
.sortedByDescending { offlineStates[it.id]?.isActive == true }
|
||||
}
|
||||
val completed = cached.count { offlineStates[it.id]?.isComplete == true }
|
||||
val running = cached.count { offlineStates[it.id]?.isActive == true }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(top = 12.dp)) {
|
||||
Text("Hors ligne", style = MaterialTheme.typography.displaySmall)
|
||||
Spacer(Modifier.size(14.dp))
|
||||
StorageCard(
|
||||
cacheSize = cacheSize,
|
||||
completed = completed,
|
||||
running = running,
|
||||
onClear = { confirmClear = true },
|
||||
)
|
||||
Spacer(Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (cached.isEmpty()) {
|
||||
item { EmptyOffline() }
|
||||
}
|
||||
|
||||
items(cached, key = { it.id }) { entry ->
|
||||
val state = offlineStates[entry.id]
|
||||
Column {
|
||||
MediaRow(
|
||||
entry = entry,
|
||||
offline = state,
|
||||
isCurrent = entry.id == currentMediaId,
|
||||
onClick = { onPlay(cached, cached.indexOf(entry)) },
|
||||
onDownload = { container.downloads.download(entry) },
|
||||
onRemoveOffline = { container.downloads.remove(entry.id) },
|
||||
)
|
||||
if (state?.isActive == true) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (state.percent / 100f).coerceIn(0f, 1f) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmClear) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmClear = false },
|
||||
title = { Text("Vider la cachette ?") },
|
||||
text = {
|
||||
Text("Tout le contenu mis de côté sera supprimé de l'appareil (${formatBytes(cacheSize)}). Les fichiers déjà présents sur l'appareil ne sont pas touchés.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
container.downloads.removeAll()
|
||||
confirmClear = false
|
||||
}) { Text("Tout libérer") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmClear = false }) { Text("Annuler") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StorageCard(
|
||||
cacheSize: Long,
|
||||
completed: Int,
|
||||
running: Int,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(20.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = formatBytes(cacheSize),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
append("$completed élément(s) disponibles sans réseau")
|
||||
if (running > 0) append(" · $running en cours")
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
if (completed > 0 || running > 0) {
|
||||
TextButton(onClick = onClear) { Text("Tout libérer") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyOffline() {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 56.dp),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text("Rien de mis de côté", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(
|
||||
text = "Appuyez sur l'icône de téléchargement d'un média distant pour le garder hors connexion.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package fr.tuxgyver.cachette.ui.player
|
||||
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import fr.tuxgyver.cachette.playback.PlayerUiState
|
||||
import fr.tuxgyver.cachette.ui.components.Artwork
|
||||
|
||||
@Composable
|
||||
fun MiniPlayer(
|
||||
state: PlayerUiState,
|
||||
positionMs: Long,
|
||||
onExpand: () -> Unit,
|
||||
onTogglePlay: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val progress = if (state.durationMs > 0) {
|
||||
(positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 8.dp,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onExpand),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
) {
|
||||
Artwork(
|
||||
uri = state.artworkUri,
|
||||
title = state.title,
|
||||
kind = state.kind,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = state.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.basicMarquee(),
|
||||
)
|
||||
Text(
|
||||
text = state.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onTogglePlay) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
|
||||
contentDescription = if (state.isPlaying) "Pause" else "Lecture",
|
||||
)
|
||||
}
|
||||
if (state.hasNext) {
|
||||
IconButton(onClick = onNext) {
|
||||
Icon(Icons.Rounded.SkipNext, contentDescription = "Suivant")
|
||||
}
|
||||
}
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(width = 0.dp, height = 2.dp),
|
||||
)
|
||||
Spacer(Modifier.size(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package fr.tuxgyver.cachette.ui.player
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material.icons.rounded.Forward30
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Replay10
|
||||
import androidx.compose.material.icons.rounded.SkipNext
|
||||
import androidx.compose.material.icons.rounded.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.PlayerView
|
||||
import fr.tuxgyver.cachette.model.MediaEntry
|
||||
import fr.tuxgyver.cachette.model.MediaKind
|
||||
import fr.tuxgyver.cachette.offline.OfflineState
|
||||
import fr.tuxgyver.cachette.playback.PlayerUiState
|
||||
import fr.tuxgyver.cachette.ui.components.Artwork
|
||||
import fr.tuxgyver.cachette.ui.components.formatDuration
|
||||
|
||||
@Composable
|
||||
fun NowPlayingScreen(
|
||||
state: PlayerUiState,
|
||||
positionMs: Long,
|
||||
entry: MediaEntry?,
|
||||
offline: OfflineState?,
|
||||
player: Player?,
|
||||
onCollapse: () -> Unit,
|
||||
onTogglePlay: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSkipBy: (Long) -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onRemoveOffline: () -> Unit,
|
||||
) {
|
||||
var scrubbing by remember { mutableStateOf<Float?>(null) }
|
||||
val displayedPosition = scrubbing?.let { (it * state.durationMs).toLong() } ?: positionMs
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.background,
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
IconButton(onClick = onCollapse) {
|
||||
Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Réduire")
|
||||
}
|
||||
Text(
|
||||
text = "En lecture",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OfflineToggle(entry, offline, onDownload, onRemoveOffline)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(0.35f))
|
||||
|
||||
if (state.kind == MediaKind.VIDEO && player != null) {
|
||||
VideoSurface(
|
||||
player = player,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(Color.Black),
|
||||
)
|
||||
} else {
|
||||
Artwork(
|
||||
uri = state.artworkUri,
|
||||
title = state.title,
|
||||
kind = state.kind,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.88f)
|
||||
.aspectRatio(1f),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(0.65f))
|
||||
|
||||
Text(
|
||||
text = state.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.basicMarquee(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = state.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(20.dp))
|
||||
|
||||
Slider(
|
||||
value = scrubbing ?: if (state.durationMs > 0) {
|
||||
(positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
||||
} else 0f,
|
||||
onValueChange = { scrubbing = it },
|
||||
onValueChangeFinished = {
|
||||
scrubbing?.let { onSeek((it * state.durationMs).toLong()) }
|
||||
scrubbing = null
|
||||
},
|
||||
enabled = state.durationMs > 0,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(formatDuration(displayedPosition), style = MaterialTheme.typography.labelMedium)
|
||||
Text(formatDuration(state.durationMs), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
IconButton(onClick = onPrevious, enabled = state.hasPrevious) {
|
||||
Icon(Icons.Rounded.SkipPrevious, contentDescription = "Précédent")
|
||||
}
|
||||
IconButton(onClick = { onSkipBy(-10_000) }) {
|
||||
Icon(Icons.Rounded.Replay10, contentDescription = "Reculer de 10 secondes")
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = onTogglePlay,
|
||||
shape = CircleShape,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(),
|
||||
modifier = Modifier.size(72.dp),
|
||||
) {
|
||||
if (state.isBuffering) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
|
||||
contentDescription = if (state.isPlaying) "Pause" else "Lecture",
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onSkipBy(30_000) }) {
|
||||
Icon(Icons.Rounded.Forward30, contentDescription = "Avancer de 30 secondes")
|
||||
}
|
||||
IconButton(onClick = onNext, enabled = state.hasNext) {
|
||||
Icon(Icons.Rounded.SkipNext, contentDescription = "Suivant")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineToggle(
|
||||
entry: MediaEntry?,
|
||||
offline: OfflineState?,
|
||||
onDownload: () -> Unit,
|
||||
onRemoveOffline: () -> Unit,
|
||||
) {
|
||||
if (entry == null || !entry.isRemote) {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
return
|
||||
}
|
||||
when {
|
||||
offline?.isComplete == true -> IconButton(onClick = onRemoveOffline) {
|
||||
Icon(
|
||||
Icons.Rounded.CheckCircle,
|
||||
contentDescription = "Disponible hors connexion — appuyer pour libérer",
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
offline?.isActive == true -> Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
else -> IconButton(onClick = onDownload) {
|
||||
Icon(Icons.Rounded.Download, contentDescription = "Mettre de côté pour plus tard")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
private fun VideoSurface(player: Player, modifier: Modifier = Modifier) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
PlayerView(context).apply {
|
||||
useController = false
|
||||
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
|
||||
}
|
||||
},
|
||||
update = { view -> view.player = player },
|
||||
onRelease = { view -> view.player = null },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package fr.tuxgyver.cachette.ui.sources
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||
import androidx.compose.material.icons.rounded.Public
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.Podcasts
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.Smartphone
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.cachette.AppContainer
|
||||
import fr.tuxgyver.cachette.model.SourceKind
|
||||
import fr.tuxgyver.cachette.sources.CatalogId
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SourcesScreen(
|
||||
container: AppContainer,
|
||||
contentPadding: PaddingValues,
|
||||
onMessage: (String) -> Unit,
|
||||
onBrowseSource: (SourceKind) -> Unit,
|
||||
onBrowseCatalog: (CatalogId) -> Unit,
|
||||
) {
|
||||
val entries by container.library.entries.collectAsStateWithLifecycle()
|
||||
var sheet by remember { mutableStateOf<ImportKind?>(null) }
|
||||
|
||||
val deviceCount = entries.count { it.source == SourceKind.LOCAL }
|
||||
val linkCount = entries.count { it.source == SourceKind.URL }
|
||||
val podcastCount = entries.count { it.source == SourceKind.PODCAST }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(top = 12.dp)) {
|
||||
Text("Sources", style = MaterialTheme.typography.displaySmall)
|
||||
Text(
|
||||
"D'où vient le contenu que vous gardez hors connexion.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SourceCard(
|
||||
icon = Icons.Rounded.Smartphone,
|
||||
title = "Appareil",
|
||||
status = "$deviceCount éléments",
|
||||
description = "Musique et vidéos déjà présentes sur le téléphone. Toujours disponibles.",
|
||||
actionLabel = "Actualiser",
|
||||
actionIcon = Icons.Rounded.Refresh,
|
||||
onAction = { container.library.refreshLocal() },
|
||||
onOpen = { onBrowseSource(SourceKind.LOCAL) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SourceCard(
|
||||
icon = Icons.Rounded.Link,
|
||||
title = "Lien direct",
|
||||
status = "$linkCount éléments",
|
||||
description = "Une URL vers un fichier audio ou vidéo. Le fichier est ensuite copié dans la cachette.",
|
||||
actionLabel = "Ajouter un lien",
|
||||
onAction = { sheet = ImportKind.URL },
|
||||
onOpen = { onBrowseSource(SourceKind.URL) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SourceCard(
|
||||
icon = Icons.Rounded.Podcasts,
|
||||
title = "Podcast",
|
||||
status = "$podcastCount épisodes",
|
||||
description = "Un flux RSS. Les épisodes deviennent téléchargeables un par un.",
|
||||
actionLabel = "Ajouter un flux",
|
||||
onAction = { sheet = ImportKind.RSS },
|
||||
onOpen = { onBrowseSource(SourceKind.PODCAST) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Rechercher, choisir, emporter. Aucun compte requis.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
items(CatalogId.entries) { catalog ->
|
||||
SourceCard(
|
||||
icon = if (catalog == CatalogId.LIBRIVOX) Icons.AutoMirrored.Rounded.MenuBook else Icons.Rounded.Public,
|
||||
title = catalog.title,
|
||||
status = catalog.license,
|
||||
description = catalog.description,
|
||||
actionLabel = "Parcourir",
|
||||
actionIcon = Icons.Rounded.Search,
|
||||
onAction = { onBrowseCatalog(catalog) },
|
||||
onOpen = { onBrowseCatalog(catalog) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
unavailableSourceCards(
|
||||
listOf(
|
||||
UnavailableSource(
|
||||
"Spotify",
|
||||
"Le SDK Spotify ne donne pas accès au flux audio : la lecture hors connexion n'existe que dans l'application Spotify. Un podcast diffusé aussi en RSS public peut en revanche être ajouté ci-dessus.",
|
||||
),
|
||||
UnavailableSource(
|
||||
"YouTube",
|
||||
"Les conditions d'utilisation interdisent d'extraire ou de stocker les flux. Le hors-ligne passe par YouTube Premium, dans leur application.",
|
||||
),
|
||||
UnavailableSource(
|
||||
"Google Drive / Photos",
|
||||
"Techniquement possible pour vos propres fichiers, mais cela demande un identifiant OAuth Google propre à votre compte développeur, qui n'est pas fourni ici.",
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val current = sheet
|
||||
if (current != null) {
|
||||
ImportSheet(
|
||||
kind = current,
|
||||
container = container,
|
||||
onDismiss = { sheet = null },
|
||||
onMessage = onMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class UnavailableSource(val title: String, val explanation: String)
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.unavailableSourceCards(sources: List<UnavailableSource>) {
|
||||
sources.forEach { source ->
|
||||
item {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(16.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column {
|
||||
Text(source.title, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
source.explanation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceCard(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
status: String,
|
||||
description: String,
|
||||
actionLabel: String,
|
||||
actionIcon: ImageVector? = null,
|
||||
onAction: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpen),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
status,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = "Parcourir",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Button(onClick = onAction) {
|
||||
if (actionIcon != null) {
|
||||
Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class ImportKind(val title: String, val hint: String) {
|
||||
URL("Ajouter un lien direct", "https://exemple.org/morceau.mp3"),
|
||||
RSS("Ajouter un flux podcast", "https://exemple.org/podcast.xml"),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImportSheet(
|
||||
kind: ImportKind,
|
||||
container: AppContainer,
|
||||
onDismiss: () -> Unit,
|
||||
onMessage: (String) -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var url by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var mode by remember { mutableStateOf(kind) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 24.dp)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Text(mode.title, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ImportKind.entries.forEach { option ->
|
||||
FilterChip(
|
||||
selected = mode == option,
|
||||
onClick = { mode = option; error = null },
|
||||
label = { Text(if (option == ImportKind.URL) "Lien direct" else "Flux podcast") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(16.dp))
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it; error = null },
|
||||
placeholder = { Text(mode.hint) },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(20.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(onClick = onDismiss, enabled = !busy) { Text("Annuler") }
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Button(
|
||||
enabled = !busy && url.isNotBlank(),
|
||||
onClick = {
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val outcome = runImport(container, mode, url)
|
||||
busy = false
|
||||
outcome.fold(
|
||||
onSuccess = { message ->
|
||||
onMessage(message)
|
||||
onDismiss()
|
||||
},
|
||||
onFailure = { error = it.message ?: "Import impossible" },
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (busy) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
Text("Importer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runImport(
|
||||
container: AppContainer,
|
||||
kind: ImportKind,
|
||||
url: String,
|
||||
): Result<String> = when (kind) {
|
||||
ImportKind.URL -> container.urlImporter.import(url).mapCatching { entry ->
|
||||
val added = container.imported.add(listOf(entry))
|
||||
if (added == 0) "« ${entry.title} » est déjà dans la bibliothèque"
|
||||
else "« ${entry.title} » ajouté"
|
||||
}
|
||||
|
||||
ImportKind.RSS -> container.rssImporter.import(url).mapCatching { feed ->
|
||||
val added = container.imported.add(feed.episodes)
|
||||
if (added == 0) "${feed.title} : aucun nouvel épisode"
|
||||
else "${feed.title} : $added épisode(s) ajouté(s)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package fr.tuxgyver.cachette.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val Violet = Color(0xFF6B4EFF)
|
||||
private val VioletDeep = Color(0xFF1B1033)
|
||||
private val Mint = Color(0xFF3FD8C0)
|
||||
private val Sand = Color(0xFFFFF7F2)
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Violet,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFE6DEFF),
|
||||
onPrimaryContainer = Color(0xFF1B1033),
|
||||
secondary = Color(0xFF11A894),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFC9F5EC),
|
||||
onSecondaryContainer = Color(0xFF04231E),
|
||||
tertiary = Color(0xFFE0629B),
|
||||
background = Sand,
|
||||
onBackground = Color(0xFF1C1B22),
|
||||
surface = Color(0xFFFFFBFF),
|
||||
onSurface = Color(0xFF1C1B22),
|
||||
surfaceVariant = Color(0xFFEDE5F2),
|
||||
onSurfaceVariant = Color(0xFF4A4458),
|
||||
outlineVariant = Color(0xFFDCD3E4),
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFFB79CFF),
|
||||
onPrimary = Color(0xFF2A1A5E),
|
||||
primaryContainer = Color(0xFF3D2A7A),
|
||||
onPrimaryContainer = Color(0xFFE9DDFF),
|
||||
secondary = Mint,
|
||||
onSecondary = Color(0xFF00382F),
|
||||
secondaryContainer = Color(0xFF005046),
|
||||
onSecondaryContainer = Color(0xFFB6F2E6),
|
||||
tertiary = Color(0xFFFFB0CC),
|
||||
background = Color(0xFF120D1C),
|
||||
onBackground = Color(0xFFE8E0F0),
|
||||
surface = VioletDeep,
|
||||
onSurface = Color(0xFFE8E0F0),
|
||||
surfaceVariant = Color(0xFF2A2038),
|
||||
onSurfaceVariant = Color(0xFFC9C0D8),
|
||||
outlineVariant = Color(0xFF3A3048),
|
||||
)
|
||||
|
||||
private val CachetteShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(14.dp),
|
||||
medium = RoundedCornerShape(20.dp),
|
||||
large = RoundedCornerShape(28.dp),
|
||||
extraLarge = RoundedCornerShape(36.dp),
|
||||
)
|
||||
|
||||
private val CachetteTypography = Typography().run {
|
||||
copy(
|
||||
displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp),
|
||||
headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.4).sp),
|
||||
headlineSmall = headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
titleLarge = titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
titleMedium = titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
labelLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 14.sp, letterSpacing = 0.1.sp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CachetteTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Palette fixe plutôt que couleur dynamique : elle garantit le contraste des libellés
|
||||
// (un fond d'écran monochrome produit un thème Material You sans contraste) et donne à
|
||||
// l'application une identité stable d'un appareil à l'autre.
|
||||
val colors = if (darkTheme) DarkColors else LightColors
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
shapes = CachetteShapes,
|
||||
typography = CachetteTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Note de musique posée sur une flèche descendante : « mis de côté, hors ligne ». -->
|
||||
<path
|
||||
android:fillColor="#FFB79CFF"
|
||||
android:pathData="M64,28 L64,58.5 a10,10 0 1,1 -6,-9.2 L58,37.5 L44,40.6 L44,64 a10,10 0 1,1 -6,-9.2 L38,35.8 L64,28 Z" />
|
||||
<path
|
||||
android:fillColor="#FF7CE7D6"
|
||||
android:pathData="M54,70 L54,80 L47,80 L54,88 L61,80 L54,80 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="splash_background">#FF120D1C</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="Theme.Cachette" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<color name="splash_background">#FFF7F2FA</color>
|
||||
<color name="ic_launcher_background">#FF1B1033</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Cachette</string>
|
||||
<string name="download_channel_name">Téléchargements</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="Theme.Cachette" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Le contenu mis en cache reste sur l'appareil : ni sauvegarde cloud, ni transfert. -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Reference in new issue
Block a user