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:
tuxgyver committed 2026-09-11 19:55:08 +02:00
commit 992fe88cc4
59 files changed
+4619

No files matched your search

+62
View File
@@ -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>
+10
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<color name="splash_background">#FF120D1C</color>
</resources>
+7
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<color name="splash_background">#FFF7F2FA</color>
<color name="ic_launcher_background">#FF1B1033</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Cachette</string>
<string name="download_channel_name">Téléchargements</string>
</resources>
+7
View File
@@ -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>