M5a frontend + M5b quarantine + M5c suggestions #30

Merged
bvandeusen merged 262 commits from dev into main 2026-05-01 07:14:20 -04:00
6 changed files with 43 additions and 61 deletions
Showing only changes of commit fa90d6d6c5 - Show all commits
@@ -47,7 +47,6 @@ import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.home.data.HomeRepository import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard import com.fabledsword.minstrel.library.widgets.ArtistCard
@@ -66,6 +65,7 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -81,7 +81,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@@ -196,11 +195,8 @@ class HomeViewModel @Inject constructor(
status.join() status.join()
} }
val uiState: StateFlow<UiState<HomeSections>> = combineHomeFlows().stateIn( val uiState: StateFlow<UiState<HomeSections>> =
scope = viewModelScope, combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UiState.Loading,
)
/** /**
* Five home-section flows merged into the sections struct without * Five home-section flows merged into the sections struct without
@@ -229,7 +225,7 @@ class HomeViewModel @Inject constructor(
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists -> observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
val merged = sections.copy(playlists = playlists) val merged = sections.copy(playlists = playlists)
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged) if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
}.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } }
} }
// ─── Screen ────────────────────────────────────────────────────────── // ─── Screen ──────────────────────────────────────────────────────────
@@ -2,22 +2,17 @@ package com.fabledsword.minstrel.library.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@HiltViewModel @HiltViewModel
class LibraryViewModel @Inject constructor( class LibraryViewModel @Inject constructor(
private val repository: LibraryRepository, private val repository: LibraryRepository,
@@ -36,14 +31,7 @@ class LibraryViewModel @Inject constructor(
UiState.Success(LibraryData(artists, albums)) UiState.Success(LibraryData(artists, albums))
} }
} }
.catch { e -> .asCacheFirstStateFlow(viewModelScope)
emit(UiState.Error(ErrorCopy.fromThrowable(e)))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UiState.Loading,
)
/** /**
* Pull-to-refresh entry point. Triggers /api/library/sync via the * Pull-to-refresh entry point. Triggers /api/library/sync via the
@@ -25,7 +25,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard import com.fabledsword.minstrel.library.widgets.ArtistCard
@@ -36,6 +35,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
@@ -46,16 +46,11 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
data class LikedSections( data class LikedSections(
@@ -100,13 +95,7 @@ class LikedTabViewModel @Inject constructor(
) { artists, albums, tracks -> ) { artists, albums, tracks ->
val sections = LikedSections(artists, albums, tracks) val sections = LikedSections(artists, albums, tracks)
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections) if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
} }.asCacheFirstStateFlow(viewModelScope)
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UiState.Loading,
)
/** /**
* Build a queue from the currently-displayed liked tracks and start * Build a queue from the currently-displayed liked tracks and start
@@ -21,13 +21,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -35,17 +35,12 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
// ─── ViewModel ─────────────────────────────────────────────────────── // ─── ViewModel ───────────────────────────────────────────────────────
@@ -81,14 +76,7 @@ class PlaylistsListViewModel @Inject constructor(
UiState.Success(list) UiState.Success(list)
} }
} }
.catch { e -> .asCacheFirstStateFlow(viewModelScope)
emit(UiState.Error(ErrorCopy.fromThrowable(e)))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UiState.Loading,
)
} }
// ─── Screen ────────────────────────────────────────────────────────── // ─── Screen ──────────────────────────────────────────────────────────
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.shared
import com.fabledsword.minstrel.api.ErrorCopy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.stateIn
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
/**
* Terminal operator for the cache-first VM idiom: a Flow that maps
* cache emissions to `UiState.Success` / `UiState.Empty` becomes a
* subscriber-active StateFlow that starts in [UiState.Loading] and
* routes any upstream throwable through [ErrorCopy] into
* [UiState.Error].
*
* Replaces the catch + stateIn(viewModelScope, WhileSubscribed,
* Loading) tail that every cache-first ViewModel writes today.
*/
fun <T> Flow<UiState<T>>.asCacheFirstStateFlow(
scope: CoroutineScope,
): StateFlow<UiState<T>> = this
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UiState.Loading,
)
@@ -2,22 +2,17 @@ package com.fabledsword.minstrel.shared.widgets.trackactions
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
/** /**
* Backs [AddToPlaylistSheet] with the caller's user-owned playlists. * Backs [AddToPlaylistSheet] with the caller's user-owned playlists.
* System playlists (For You / Discover / etc.) are filtered out at * System playlists (For You / Discover / etc.) are filtered out at
@@ -42,10 +37,5 @@ class AddToPlaylistViewModel @Inject constructor(
} }
emit(UiState.Loading) emit(UiState.Loading)
} }
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } .asCacheFirstStateFlow(viewModelScope)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UiState.Loading,
)
} }