fix(ci): scope integration Postgres discovery to this job's network #53
@@ -10,6 +10,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -17,27 +18,34 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||||
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
@Inject lateinit var seedCache: DetailSeedCache
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent { App() }
|
setContent { App(seedCache = seedCache) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun App(
|
private fun App(
|
||||||
|
seedCache: DetailSeedCache,
|
||||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||||
gate: AuthGateViewModel = hiltViewModel(),
|
gate: AuthGateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||||
|
CompositionLocalProvider(LocalDetailSeedCache provides seedCache) {
|
||||||
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
||||||
val resolved = startDestination
|
val resolved = startDestination
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
@@ -57,6 +65,7 @@ private fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BootSplash() {
|
private fun BootSplash() {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ fun AlbumDetailScreen(
|
|||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
AlbumDetailUiState.Loading -> LoadingCentered()
|
is AlbumDetailUiState.Loading -> LoadingCentered()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
@@ -125,7 +125,8 @@ fun AlbumDetailScreen(
|
|||||||
|
|
||||||
private fun titleFor(state: AlbumDetailUiState): String = when (state) {
|
private fun titleFor(state: AlbumDetailUiState): String = when (state) {
|
||||||
is AlbumDetailUiState.Success -> state.detail.album.title
|
is AlbumDetailUiState.Success -> state.detail.album.title
|
||||||
else -> "Album"
|
is AlbumDetailUiState.Loading -> state.seed?.title ?: "Album"
|
||||||
|
is AlbumDetailUiState.Error -> "Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+12
-3
@@ -7,7 +7,9 @@ import androidx.navigation.toRoute
|
|||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -23,7 +25,7 @@ import javax.inject.Inject
|
|||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface AlbumDetailUiState {
|
sealed interface AlbumDetailUiState {
|
||||||
data object Loading : AlbumDetailUiState
|
data class Loading(val seed: AlbumRef? = null) : AlbumDetailUiState
|
||||||
data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState
|
data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState
|
||||||
data class Error(val message: String) : AlbumDetailUiState
|
data class Error(val message: String) : AlbumDetailUiState
|
||||||
}
|
}
|
||||||
@@ -33,13 +35,15 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
private val repository: LibraryRepository,
|
private val repository: LibraryRepository,
|
||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
private val seedCache: DetailSeedCache,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val route: AlbumDetail = savedStateHandle.toRoute()
|
private val route: AlbumDetail = savedStateHandle.toRoute()
|
||||||
val albumId: String = route.id
|
val albumId: String = route.id
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AlbumDetailUiState>(AlbumDetailUiState.Loading)
|
private val internal =
|
||||||
|
MutableStateFlow<AlbumDetailUiState>(AlbumDetailUiState.Loading())
|
||||||
val uiState: StateFlow<AlbumDetailUiState> = internal.asStateFlow()
|
val uiState: StateFlow<AlbumDetailUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
val albumLiked: StateFlow<Boolean> =
|
val albumLiked: StateFlow<Boolean> =
|
||||||
@@ -64,7 +68,12 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
internal.value = AlbumDetailUiState.Loading
|
val carried = when (val cur = internal.value) {
|
||||||
|
is AlbumDetailUiState.Loading -> cur.seed
|
||||||
|
is AlbumDetailUiState.Success -> cur.detail.album
|
||||||
|
is AlbumDetailUiState.Error -> null
|
||||||
|
}
|
||||||
|
internal.value = AlbumDetailUiState.Loading(carried ?: seedCache.peekAlbum(albumId))
|
||||||
try {
|
try {
|
||||||
val detail = repository.refreshAlbumDetail(albumId)
|
val detail = repository.refreshAlbumDetail(albumId)
|
||||||
internal.value = AlbumDetailUiState.Success(detail)
|
internal.value = AlbumDetailUiState.Success(detail)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ fun ArtistDetailScreen(
|
|||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
ArtistDetailUiState.Loading -> LoadingCentered()
|
is ArtistDetailUiState.Loading -> LoadingCentered()
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
@@ -98,7 +98,8 @@ fun ArtistDetailScreen(
|
|||||||
|
|
||||||
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||||
else -> "Artist"
|
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
||||||
|
is ArtistDetailUiState.Error -> "Artist"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+12
-3
@@ -7,7 +7,9 @@ import androidx.navigation.toRoute
|
|||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -22,7 +24,7 @@ import javax.inject.Inject
|
|||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface ArtistDetailUiState {
|
sealed interface ArtistDetailUiState {
|
||||||
data object Loading : ArtistDetailUiState
|
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||||
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
||||||
data class Error(val message: String) : ArtistDetailUiState
|
data class Error(val message: String) : ArtistDetailUiState
|
||||||
}
|
}
|
||||||
@@ -32,13 +34,15 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
private val repository: LibraryRepository,
|
private val repository: LibraryRepository,
|
||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
private val seedCache: DetailSeedCache,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val route: ArtistDetail = savedStateHandle.toRoute()
|
private val route: ArtistDetail = savedStateHandle.toRoute()
|
||||||
val artistId: String = route.id
|
val artistId: String = route.id
|
||||||
|
|
||||||
private val internal = MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading)
|
private val internal =
|
||||||
|
MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading())
|
||||||
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
|
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
val artistLiked: StateFlow<Boolean> =
|
val artistLiked: StateFlow<Boolean> =
|
||||||
@@ -61,7 +65,12 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
internal.value = ArtistDetailUiState.Loading
|
val carried = when (val cur = internal.value) {
|
||||||
|
is ArtistDetailUiState.Loading -> cur.seed
|
||||||
|
is ArtistDetailUiState.Success -> cur.detail.artist
|
||||||
|
is ArtistDetailUiState.Error -> null
|
||||||
|
}
|
||||||
|
internal.value = ArtistDetailUiState.Loading(carried ?: seedCache.peekArtist(artistId))
|
||||||
try {
|
try {
|
||||||
val detail = repository.refreshArtistDetail(artistId)
|
val detail = repository.refreshArtistDetail(artistId)
|
||||||
internal.value = ArtistDetailUiState.Success(detail)
|
internal.value = ArtistDetailUiState.Success(detail)
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ import com.composables.icons.lucide.Disc3
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import com.fabledsword.minstrel.models.AlbumRef
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp
|
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp
|
||||||
* square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in
|
* square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in
|
||||||
* Phase 5.5.
|
* Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache]
|
||||||
|
* before firing onClick so the detail screen's AppBar renders title /
|
||||||
|
* cover / counts immediately without waiting for the fetch.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumCard(
|
fun AlbumCard(
|
||||||
@@ -40,10 +43,14 @@ fun AlbumCard(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val seedCache = LocalDetailSeedCache.current
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(176.dp)
|
.width(176.dp)
|
||||||
.clickable(onClick = onClick),
|
.clickable {
|
||||||
|
seedCache.stashAlbum(album)
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ import coil3.compose.AsyncImage
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One artist tile. Circular cover; name centered beneath. Tap fires
|
* One artist tile. Circular cover; name centered beneath. Tap fires
|
||||||
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5.
|
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the
|
||||||
|
* `artist` in [LocalDetailSeedCache] on click so the ArtistDetail
|
||||||
|
* AppBar renders the name before the fetch lands.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistCard(
|
fun ArtistCard(
|
||||||
@@ -38,10 +41,14 @@ fun ArtistCard(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val seedCache = LocalDetailSeedCache.current
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(144.dp)
|
.width(144.dp)
|
||||||
.clickable(onClick = onClick),
|
.clickable {
|
||||||
|
seedCache.stashArtist(artist)
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.fabledsword.minstrel.nav
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory hand-off for detail-screen "seed" data — the lightweight
|
||||||
|
* Ref the caller already has when navigating to AlbumDetail /
|
||||||
|
* ArtistDetail / PlaylistDetail. The detail VM peeks the seed on
|
||||||
|
* init so the AppBar header (title / cover / counts) renders
|
||||||
|
* immediately, before the network round-trip completes.
|
||||||
|
*
|
||||||
|
* Mirrors Flutter's go_router `extra:` parameter pattern. Bounded
|
||||||
|
* per-bucket via insertion-order LRU so back/forward navigation
|
||||||
|
* doesn't grow unbounded.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DetailSeedCache @Inject constructor() {
|
||||||
|
private val albums = lru<AlbumRef>(MAX_ENTRIES)
|
||||||
|
private val artists = lru<ArtistRef>(MAX_ENTRIES)
|
||||||
|
private val playlists = lru<PlaylistRef>(MAX_ENTRIES)
|
||||||
|
|
||||||
|
@Synchronized fun stashAlbum(ref: AlbumRef) { albums[ref.id] = ref }
|
||||||
|
@Synchronized fun stashArtist(ref: ArtistRef) { artists[ref.id] = ref }
|
||||||
|
@Synchronized fun stashPlaylist(ref: PlaylistRef) { playlists[ref.id] = ref }
|
||||||
|
|
||||||
|
@Synchronized fun peekAlbum(id: String): AlbumRef? = albums[id]
|
||||||
|
@Synchronized fun peekArtist(id: String): ArtistRef? = artists[id]
|
||||||
|
@Synchronized fun peekPlaylist(id: String): PlaylistRef? = playlists[id]
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val MAX_ENTRIES = 32
|
||||||
|
private fun <V> lru(max: Int): MutableMap<String, V> =
|
||||||
|
object : LinkedHashMap<String, V>(max, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: Map.Entry<String, V>) = size > max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CompositionLocal carrying the app's [DetailSeedCache]. Provided
|
||||||
|
* at the [com.fabledsword.minstrel.MainActivity] root so any screen
|
||||||
|
* can stash a seed before calling `navController.navigate(...)`.
|
||||||
|
*/
|
||||||
|
val LocalDetailSeedCache = staticCompositionLocalOf<DetailSeedCache> {
|
||||||
|
error("DetailSeedCache not provided — wrap content in CompositionLocalProvider")
|
||||||
|
}
|
||||||
+16
-5
@@ -65,6 +65,7 @@ import com.fabledsword.minstrel.models.PlaylistTrackRef
|
|||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
@@ -98,7 +99,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
sealed interface PlaylistDetailUiState {
|
sealed interface PlaylistDetailUiState {
|
||||||
data object Loading : PlaylistDetailUiState
|
data class Loading(val seed: PlaylistRef? = null) : PlaylistDetailUiState
|
||||||
data class Success(val detail: PlaylistDetailRef) : PlaylistDetailUiState
|
data class Success(val detail: PlaylistDetailRef) : PlaylistDetailUiState
|
||||||
data class Error(val message: String) : PlaylistDetailUiState
|
data class Error(val message: String) : PlaylistDetailUiState
|
||||||
}
|
}
|
||||||
@@ -111,13 +112,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
private val seedCache: DetailSeedCache,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val route: PlaylistDetail = savedStateHandle.toRoute()
|
private val route: PlaylistDetail = savedStateHandle.toRoute()
|
||||||
val playlistId: String = route.id
|
val playlistId: String = route.id
|
||||||
|
|
||||||
private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading)
|
private val internal =
|
||||||
|
MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading())
|
||||||
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow()
|
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,7 +181,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
internal.value = PlaylistDetailUiState.Loading
|
val carried = when (val cur = internal.value) {
|
||||||
|
is PlaylistDetailUiState.Loading -> cur.seed
|
||||||
|
is PlaylistDetailUiState.Success -> cur.detail.playlist
|
||||||
|
is PlaylistDetailUiState.Error -> null
|
||||||
|
}
|
||||||
|
internal.value = PlaylistDetailUiState.Loading(
|
||||||
|
carried ?: seedCache.peekPlaylist(playlistId),
|
||||||
|
)
|
||||||
val detail = repository.refreshDetail(playlistId)
|
val detail = repository.refreshDetail(playlistId)
|
||||||
internal.value = PlaylistDetailUiState.Success(detail)
|
internal.value = PlaylistDetailUiState.Success(detail)
|
||||||
} catch (
|
} catch (
|
||||||
@@ -287,7 +297,7 @@ private fun PlaylistDetailContent(
|
|||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
PlaylistDetailUiState.Loading -> LoadingCentered()
|
is PlaylistDetailUiState.Loading -> LoadingCentered()
|
||||||
is PlaylistDetailUiState.Error -> ErrorBlock(state.message, viewModel::refresh)
|
is PlaylistDetailUiState.Error -> ErrorBlock(state.message, viewModel::refresh)
|
||||||
is PlaylistDetailUiState.Success -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
@@ -314,7 +324,8 @@ private fun PlaylistDetailContent(
|
|||||||
|
|
||||||
private fun currentTitle(state: PlaylistDetailUiState): String = when (state) {
|
private fun currentTitle(state: PlaylistDetailUiState): String = when (state) {
|
||||||
is PlaylistDetailUiState.Success -> state.detail.playlist.name
|
is PlaylistDetailUiState.Success -> state.detail.playlist.name
|
||||||
else -> "Playlist"
|
is PlaylistDetailUiState.Loading -> state.seed?.name ?: "Playlist"
|
||||||
|
is PlaylistDetailUiState.Error -> "Playlist"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Body composables ────────────────────────────────────────────────
|
// ─── Body composables ────────────────────────────────────────────────
|
||||||
|
|||||||
+6
-1
@@ -26,6 +26,7 @@ import coil3.compose.AsyncImage
|
|||||||
import com.composables.icons.lucide.ListMusic
|
import com.composables.icons.lucide.ListMusic
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,10 +44,14 @@ fun PlaylistCard(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val seedCache = LocalDetailSeedCache.current
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(176.dp)
|
.width(176.dp)
|
||||||
.clickable(onClick = onClick),
|
.clickable {
|
||||||
|
seedCache.stashPlaylist(playlist)
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user