feat: web UI auth — login page, session store, Shell, TanStack Query #17

Merged
bvandeusen merged 262 commits from dev into main 2026-04-23 07:22:53 -04:00
5 changed files with 175 additions and 13 deletions
Showing only changes of commit 965ec412d1 - Show all commits
@@ -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,
)
} }
} }
@@ -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(