feat: web UI auth — login page, session store, Shell, TanStack Query #17
@@ -30,6 +30,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -95,7 +96,11 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
// ─── Composable ──────────────────────────────────────────────────────
|
// ─── Composable ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryTab(viewModel: HistoryTabViewModel = hiltViewModel()) {
|
fun HistoryTab(
|
||||||
|
onNavigateToAlbum: (String) -> Unit = {},
|
||||||
|
onNavigateToArtist: (String) -> Unit = {},
|
||||||
|
viewModel: HistoryTabViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
@@ -111,23 +116,40 @@ fun HistoryTab(viewModel: HistoryTabViewModel = hiltViewModel()) {
|
|||||||
is HistoryUiState.Success -> HistoryList(
|
is HistoryUiState.Success -> HistoryList(
|
||||||
entries = s.entries,
|
entries = s.entries,
|
||||||
onPlay = viewModel::playTrack,
|
onPlay = viewModel::playTrack,
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HistoryList(entries: List<HistoryEntry>, onPlay: (TrackRef) -> Unit) {
|
private fun HistoryList(
|
||||||
|
entries: List<HistoryEntry>,
|
||||||
|
onPlay: (TrackRef) -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(items = entries, key = { it.id }) { entry ->
|
items(items = entries, key = { it.id }) { entry ->
|
||||||
HistoryRow(entry = entry, onClick = { onPlay(entry.track) })
|
HistoryRow(
|
||||||
|
entry = entry,
|
||||||
|
onClick = { onPlay(entry.track) },
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HistoryRow(entry: HistoryEntry, onClick: () -> Unit) {
|
private fun HistoryRow(
|
||||||
|
entry: HistoryEntry,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -158,6 +180,11 @@ private fun HistoryRow(entry: HistoryEntry, onClick: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
TrackActionsButton(
|
||||||
|
track = entry.track,
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -41,6 +44,7 @@ import com.fabledsword.minstrel.nav.Library
|
|||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
|
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
|
||||||
@@ -60,9 +64,16 @@ import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
|||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: LibraryViewModel = hiltViewModel(),
|
viewModel: LibraryViewModel = hiltViewModel(),
|
||||||
|
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
var selectedTab by remember { mutableIntStateOf(0) }
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
val tabs = LIBRARY_TABS
|
val tabs = LIBRARY_TABS
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
trackActionsViewModel.transientMessages.collect { msg ->
|
||||||
|
snackbarHostState.showSnackbar(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -91,12 +102,16 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||||
when (selectedTab) {
|
when (selectedTab) {
|
||||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||||
TAB_HISTORY -> HistoryTab()
|
TAB_HISTORY -> HistoryTab(
|
||||||
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
)
|
||||||
TAB_LIKED -> LikedTab(navController = navController)
|
TAB_LIKED -> LikedTab(navController = navController)
|
||||||
TAB_HIDDEN -> HiddenTab()
|
TAB_HIDDEN -> HiddenTab()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.player.PlayerController
|
|||||||
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.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -140,6 +141,8 @@ fun LikedTab(
|
|||||||
onTrackClick = { index ->
|
onTrackClick = { index ->
|
||||||
viewModel.playTracks(s.sections.tracks, index)
|
viewModel.playTracks(s.sections.tracks, index)
|
||||||
},
|
},
|
||||||
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +154,8 @@ private fun LikedContent(
|
|||||||
onArtistClick: (String) -> Unit,
|
onArtistClick: (String) -> Unit,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
onTrackClick: (Int) -> Unit,
|
onTrackClick: (Int) -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -185,7 +190,12 @@ private fun LikedContent(
|
|||||||
SectionHeader("Tracks", sections.tracks.size)
|
SectionHeader("Tracks", sections.tracks.size)
|
||||||
}
|
}
|
||||||
itemsIndexed(items = sections.tracks, key = { _, t -> "trk-${t.id}" }) { index, track ->
|
itemsIndexed(items = sections.tracks, key = { _, t -> "trk-${t.id}" }) { index, track ->
|
||||||
LikedTrackRow(track = track, onClick = { onTrackClick(index) })
|
LikedTrackRow(
|
||||||
|
track = track,
|
||||||
|
onClick = { onTrackClick(index) },
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +226,12 @@ private fun SectionHeader(label: String, count: Int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LikedTrackRow(track: TrackRef, onClick: () -> Unit) {
|
private fun LikedTrackRow(
|
||||||
|
track: TrackRef,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -242,6 +257,11 @@ private fun LikedTrackRow(track: TrackRef, onClick: () -> Unit) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
TrackActionsButton(
|
||||||
|
track = track,
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
@@ -27,9 +27,12 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -55,6 +58,8 @@ import com.composables.icons.lucide.Shuffle
|
|||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.models.PlaylistTrackRef
|
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.ArtistDetail
|
||||||
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
|
||||||
@@ -62,6 +67,8 @@ import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
|
|||||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -152,8 +159,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
fun PlaylistDetailScreen(
|
fun PlaylistDetailScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: PlaylistDetailViewModel = hiltViewModel(),
|
viewModel: PlaylistDetailViewModel = hiltViewModel(),
|
||||||
|
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
trackActionsViewModel.transientMessages.collect { msg ->
|
||||||
|
snackbarHostState.showSnackbar(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -166,6 +180,7 @@ fun PlaylistDetailScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
@@ -184,6 +199,8 @@ fun PlaylistDetailScreen(
|
|||||||
viewModel.play(s.detail.tracks, startTrackId = id)
|
viewModel.play(s.detail.tracks, startTrackId = id)
|
||||||
},
|
},
|
||||||
onToggleTrackLike = viewModel::toggleLikeTrack,
|
onToggleTrackLike = viewModel::toggleLikeTrack,
|
||||||
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,6 +223,8 @@ private fun PlaylistDetailBody(
|
|||||||
onShuffleAll: () -> Unit,
|
onShuffleAll: () -> Unit,
|
||||||
onTrackClick: (String) -> Unit,
|
onTrackClick: (String) -> Unit,
|
||||||
onToggleTrackLike: (String) -> Unit,
|
onToggleTrackLike: (String) -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -219,6 +238,8 @@ private fun PlaylistDetailBody(
|
|||||||
liked = row.trackId != null && row.trackId in likedTrackIds,
|
liked = row.trackId != null && row.trackId in likedTrackIds,
|
||||||
onClick = { row.trackId?.let(onTrackClick) },
|
onClick = { row.trackId?.let(onTrackClick) },
|
||||||
onToggleLike = { row.trackId?.let(onToggleTrackLike) },
|
onToggleLike = { row.trackId?.let(onToggleTrackLike) },
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
@@ -327,6 +348,8 @@ private fun TrackRow(
|
|||||||
liked: Boolean,
|
liked: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val enabled = row.isAvailable
|
val enabled = row.isAvailable
|
||||||
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
|
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
|
||||||
@@ -366,6 +389,11 @@ private fun TrackRow(
|
|||||||
)
|
)
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||||
|
TrackActionsButton(
|
||||||
|
track = row.toTrackRef(),
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
@@ -48,16 +50,26 @@ import com.fabledsword.minstrel.models.SearchResponseRef
|
|||||||
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.shared.widgets.TrackCoverThumb
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: SearchViewModel = hiltViewModel(),
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
|
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
trackActionsViewModel.transientMessages.collect { msg ->
|
||||||
|
snackbarHostState.showSnackbar(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -84,6 +96,7 @@ fun SearchScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||||
ResultsPane(
|
ResultsPane(
|
||||||
@@ -91,6 +104,8 @@ fun SearchScreen(
|
|||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onTrackPlay = viewModel::playTrack,
|
onTrackPlay = viewModel::playTrack,
|
||||||
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +141,8 @@ private fun ResultsPane(
|
|||||||
onArtistClick: (String) -> Unit,
|
onArtistClick: (String) -> Unit,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
@@ -135,7 +152,14 @@ private fun ResultsPane(
|
|||||||
if (state.response.isEmpty) {
|
if (state.response.isEmpty) {
|
||||||
CenteredHint("No matches for that query.")
|
CenteredHint("No matches for that query.")
|
||||||
} else {
|
} else {
|
||||||
ResultsList(state.response, onArtistClick, onAlbumClick, onTrackPlay)
|
ResultsList(
|
||||||
|
response = state.response,
|
||||||
|
onArtistClick = onArtistClick,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onTrackPlay = onTrackPlay,
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +171,8 @@ private fun ResultsList(
|
|||||||
onArtistClick: (String) -> Unit,
|
onArtistClick: (String) -> Unit,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -154,7 +180,7 @@ private fun ResultsList(
|
|||||||
) {
|
) {
|
||||||
artistsSection(response.artists, onArtistClick)
|
artistsSection(response.artists, onArtistClick)
|
||||||
albumsSection(response.albums, onAlbumClick)
|
albumsSection(response.albums, onAlbumClick)
|
||||||
tracksSection(response.tracks, onTrackPlay)
|
tracksSection(response.tracks, onTrackPlay, onNavigateToAlbum, onNavigateToArtist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,20 +219,66 @@ private fun LazyListScope.albumsSection(
|
|||||||
private fun LazyListScope.tracksSection(
|
private fun LazyListScope.tracksSection(
|
||||||
tracks: List<TrackRef>,
|
tracks: List<TrackRef>,
|
||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (tracks.isEmpty()) return
|
if (tracks.isEmpty()) return
|
||||||
item { SectionHeader("Tracks") }
|
item { SectionHeader("Tracks") }
|
||||||
items(items = tracks, key = { "trk-${it.id}" }) { track ->
|
items(items = tracks, key = { "trk-${it.id}" }) { track ->
|
||||||
TextRow(
|
TrackResultRow(
|
||||||
primary = track.title,
|
track = track,
|
||||||
secondary = track.artistName.takeIf { it.isNotEmpty() }
|
|
||||||
?: track.albumTitle.takeIf { it.isNotEmpty() },
|
|
||||||
onClick = { onTrackPlay(track) },
|
onClick = { onTrackPlay(track) },
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackResultRow(
|
||||||
|
track: TrackRef,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = track.streamUrl.isNotEmpty(), onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
TrackCoverThumb(coverUrl = track.coverUrl, contentDescription = track.title)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = track.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val secondary = track.artistName.takeIf { it.isNotEmpty() }
|
||||||
|
?: track.albumTitle.takeIf { it.isNotEmpty() }
|
||||||
|
if (secondary != null) {
|
||||||
|
Text(
|
||||||
|
text = secondary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrackActionsButton(
|
||||||
|
track = track,
|
||||||
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
onNavigateToArtist = onNavigateToArtist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
private fun SectionHeader(text: String) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user