Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import app.waveflow.ui.browse.AlbumDetailScreen
import app.waveflow.ui.browse.AlbumsScreen
import app.waveflow.ui.browse.ArtistDetailScreen
import app.waveflow.ui.browse.ArtistsScreen
import app.waveflow.ui.cache.CacheViewModel
import app.waveflow.ui.library.LibraryScreen
import app.waveflow.ui.library.LibraryViewModel
import app.waveflow.ui.navigation.Routes
Expand Down Expand Up @@ -113,13 +114,15 @@ private fun WaveFlowRoot() {
val searchViewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory)
val serverViewModel: ServerViewModel = viewModel(factory = ServerViewModel.Factory)
val catalogViewModel: CatalogViewModel = viewModel(factory = CatalogViewModel.Factory)
val cacheViewModel: CacheViewModel = viewModel(factory = CacheViewModel.Factory)

val library by libraryViewModel.library.collectAsStateWithLifecycle()
val playerState by playerViewModel.state.collectAsStateWithLifecycle()
val playlistsState by playlistsViewModel.state.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.query.collectAsStateWithLifecycle()
val searchResults by searchViewModel.results.collectAsStateWithLifecycle()
val serverState by serverViewModel.state.collectAsStateWithLifecycle()
val cacheState by cacheViewModel.state.collectAsStateWithLifecycle()
// Les états du catalogue sont collectés dans leurs destinations, et non
// ici : chargés à la racine, chaque page reçue recomposerait le Scaffold,
// le NavHost et tous les écrans.
Expand Down Expand Up @@ -445,12 +448,19 @@ private fun WaveFlowRoot() {

composable(Routes.SERVER_ACCOUNT) {
serverState.connected?.let { session ->
// La taille bouge à chaque piste lue : elle se
// relève à l'ouverture, pas une fois pour toutes.
LaunchedEffect(Unit) { cacheViewModel.refresh() }

ServerAccountScreen(
session = session,
cache = cacheState,
onDisconnect = {
serverViewModel.disconnect()
navController.popBackStack()
},
onClearCache = cacheViewModel::clear,
onDismissCacheError = cacheViewModel::dismissError,
bottomPadding = listBottomPadding,
)
}
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/app/waveflow/WaveFlowApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import app.waveflow.data.remote.ServerImageAuthInterceptor
import app.waveflow.data.remote.ServerSessionRepository
import app.waveflow.playback.Media3PlaybackController
import app.waveflow.playback.PlaybackController
import app.waveflow.playback.RemoteMediaCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -86,6 +87,16 @@ class AppContainer(app: Application) {
*/
fun createPlaybackController(): PlaybackController = Media3PlaybackController(appContext)

/**
* Cache des pistes distantes, unique pour le processus.
*
* Porté ici et non par le service : `SimpleCache` refuse d'ouvrir deux fois
* le même répertoire, et l'écran des réglages doit pouvoir en lire la taille
* et le vider pendant que le service tourne. Il vit donc aussi longtemps que
* le processus, ce qui suffit — le verrou tombe avec lui.
*/
val remoteMediaCache: RemoteMediaCache by lazy { RemoteMediaCache(appContext) }

/**
* Un seul transport pour tous les appels serveur : un pool de connexions
* partagé, et surtout un seul endroit qui classe les erreurs.
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/app/waveflow/playback/PlaybackCache.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app.waveflow.playback

/**
* Ce que les réglages ont besoin de savoir du cache de lecture.
*
* Volontairement plus étroit que [RemoteMediaCache] : l'écran n'a que faire de
* la chaîne de sources, et le réduire ainsi le rend éprouvable sans ouvrir de
* vrai répertoire de cache.
*/
interface PlaybackCache {

/** Plafond au-delà duquel les pistes les plus anciennes sont évincées. */
val maxBytes: Long

/** Place occupée, à l'octet près. */
suspend fun usedBytes(): Long

/** Retire tout ce que le cache contient. */
suspend fun clear()
}
11 changes: 4 additions & 7 deletions app/src/main/java/app/waveflow/playback/PlaybackService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import kotlinx.coroutines.cancel
class PlaybackService : MediaSessionService() {

private var mediaSession: MediaSession? = null
private var mediaCache: RemoteMediaCache? = null

/**
* Portée des chargements de pochette : ils n'ont plus de destinataire une
Expand All @@ -43,8 +42,7 @@ class PlaybackService : MediaSessionService() {
// où le lecteur en a besoin, et le cache s'intercale avant lui pour
// qu'une piste déjà lue ne redemande ni ticket ni octets.
val container = (application as WaveFlowApp).container
mediaCache = RemoteMediaCache(this)
val dataSourceFactory = mediaCache!!.dataSourceFactory(
val dataSourceFactory = container.remoteMediaCache.dataSourceFactory(
RemoteStreamResolver(container.catalogRepository),
)

Expand Down Expand Up @@ -93,10 +91,9 @@ class PlaybackService : MediaSessionService() {
}
mediaSession = null
artworkScope.cancel()
// Le verrou du répertoire de cache subsisterait sans ça, et la
// prochaine ouverture échouerait.
mediaCache?.release()
mediaCache = null
// Le cache n'est pas relâché ici : il appartient au conteneur, qui le
// partage avec l'écran des réglages. Son verrou tombe avec le processus,
// et le service peut redémarrer sur la même instance.
super.onDestroy()
}
}
29 changes: 27 additions & 2 deletions app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

/**
Expand All @@ -21,7 +23,7 @@ import java.io.File
* Le répertoire est celui du cache applicatif : le système peut le vider quand
* la place manque, ce qui est exactement le contrat qu'on veut ici.
*/
class RemoteMediaCache(context: Context) {
class RemoteMediaCache(context: Context) : PlaybackCache {

private val appContext = context.applicationContext

Expand Down Expand Up @@ -62,7 +64,30 @@ class RemoteMediaCache(context: Context) {
return DefaultDataSource.Factory(appContext, cached)
}

/** À la destruction du service : sans quoi le verrou du répertoire subsiste. */
override val maxBytes: Long = MAX_BYTES

/**
* Place occupée, à l'octet près.
*
* Lue depuis l'index du cache et non par un parcours du répertoire : c'est
* le même chiffre que celui sur lequel l'éviction se déclenche.
*/
override suspend fun usedBytes(): Long = withContext(Dispatchers.IO) { cache.cacheSpace }

/**
* Vide le cache de tout ce qu'il contient.
*
* Rien n'est perdu : chaque piste reste sur le serveur, et une lecture la
* retéléchargera. Une lecture en cours n'est pas interrompue — elle tient
* déjà ses fichiers ouverts, et les supprimer ne les lui retire pas.
*/
override suspend fun clear() = withContext(Dispatchers.IO) {
// `keys` reflète l'index à l'instant de l'appel ; en prendre une copie
// évite de le parcourir pendant qu'on le modifie.
cache.keys.toList().forEach { cache.removeResource(it) }
}

/** À la fermeture : sans quoi le verrou du répertoire subsiste. */
fun release() {
cache.release()
}
Expand Down
122 changes: 122 additions & 0 deletions app/src/main/java/app/waveflow/ui/cache/CacheSection.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package app.waveflow.ui.cache

import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp

/**
* Le cache de lecture, montré et vidable.
*
* Il ne contient que des pistes du serveur — les fichiers de l'appareil n'y
* passent jamais — d'où sa place sous le compte plutôt que dans des réglages
* généraux qui n'existent pas.
*/
@Composable
fun CacheSection(
state: CacheUiState,
onClear: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var confirmation by remember { mutableStateOf(false) }

Card(modifier = modifier.fillMaxWidth()) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
text = "Cache de lecture",
style = MaterialTheme.typography.titleMedium,
)

Text(
text = when {
state.usedBytes == null -> "Calcul de la place occupée…"
else -> "%s occupés sur %s".format(
Formatter.formatShortFileSize(context, state.usedBytes),
Formatter.formatShortFileSize(context, state.maxBytes),
)
},
style = MaterialTheme.typography.bodyMedium,
)

if (state.usedBytes != null && state.maxBytes > 0L) {
LinearProgressIndicator(
progress = {
(state.usedBytes.toFloat() / state.maxBytes).coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth(),
)
}

Text(
text = "Les pistes déjà écoutées depuis le serveur repartent d'ici plutôt " +
"que du réseau. Les vider ne perd rien : elles restent sur le serveur.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

state.errorMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}

TextButton(
// Rien à vider, ou vidage déjà en cours.
enabled = !state.isClearing && state.usedBytes != null && !state.isEmpty,
onClick = { confirmation = true },
) {
Text(if (state.isClearing) "Vidage…" else "Vider le cache")
}
}
}

if (confirmation) {
AlertDialog(
onDismissRequest = { confirmation = false },
title = { Text("Vider le cache ?") },
// Ce que ça coûte vraiment : du réseau, pas de la musique perdue.
text = {
Text(
"Les pistes déjà téléchargées seront reprises au serveur à la " +
"prochaine écoute.",
)
},
confirmButton = {
TextButton(
onClick = {
confirmation = false
onDismissError()
onClear()
},
) { Text("Vider") }
},
dismissButton = {
TextButton(onClick = { confirmation = false }) { Text("Annuler") }
},
)
}
}
Loading