From cb27514b9b77a783929355dca176bfbd3854c9b9 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 3 Jul 2026 22:21:06 +0600 Subject: [PATCH] feat: add GhostIconButton component and enhance button interactions in TrackList --- .../krtirtho/spotube/core/ui/base/Buttons.kt | 51 ++- .../spotube/core/ui/component/TrackList.kt | 351 ++++++++++++------ .../spotube/core/ui/component/TrackOptions.kt | 3 +- 3 files changed, 289 insertions(+), 116 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index a3e7a6d4..3b36a018 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -237,6 +237,7 @@ fun OutlineButton( enabled: Boolean = true, shape: androidx.compose.ui.graphics.Shape = ButtonShape, contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp), + hoverOnly: Boolean = false, content: @Composable RowScope.() -> Unit, ) { val colors = rememberButtonColors() @@ -252,17 +253,27 @@ fun OutlineButton( .defaultMinSize(minHeight = ButtonMinHeight) .hoverable(interactionSource = interactionSource, enabled = enabled) .graphicsLayer { translationY = lift.toPx() } - .then(buttonShadow(shape, isPressed, primary = false, colors, hovered = isHovered)) + .then( + if (hoverOnly && !isHovered) Modifier else buttonShadow( + shape, + isPressed, + primary = false, + colors, + hovered = isHovered + ) + ) .clip(shape) - .background(gradient, shape) - .border(BorderStroke(0.5.dp, border), shape) + .then( + if (hoverOnly && !isHovered) Modifier else Modifier.background(gradient, shape) + .border(BorderStroke(0.5.dp, border), shape) + ) .clickable( enabled = enabled, interactionSource = interactionSource, indication = ripple(), onClick = onClick, ) - .drawWithCache { + .then(if (hoverOnly && !isHovered) Modifier else Modifier.drawWithCache { val highlightBrush = Brush.verticalGradient( colors = listOf(colors.highlight, Color.Transparent), startY = 0f, @@ -276,7 +287,7 @@ fun OutlineButton( size = size, ) } - } + }) .padding(contentPadding), contentAlignment = Alignment.Center, ) { @@ -427,6 +438,26 @@ fun IconButton( content() } } +@Composable + +fun GhostIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: androidx.compose.ui.graphics.Shape = ButtonShape, + content: @Composable () -> Unit, +) { + OutlineButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + contentPadding = PaddingValues(8.dp), + hoverOnly = true, + ) { + content() + } +} @Composable fun PrimaryIconButton( @@ -579,7 +610,15 @@ fun ButtonGroup( Surface( modifier = modifier .defaultMinSize(minHeight = ButtonMinHeight) - .then(buttonShadow(GroupShape, pressed = false, primary = false, colors, hovered = false)), + .then( + buttonShadow( + GroupShape, + pressed = false, + primary = false, + colors, + hovered = false + ) + ), shape = GroupShape, color = Color.Transparent, border = BorderStroke(0.5.dp, colors.border), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 139f34bb..217762af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -21,13 +21,16 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -41,12 +44,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -58,11 +62,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.focusModifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -79,9 +88,12 @@ import dev.krtirtho.spotube.core.ui.base.ButtonGroup import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider import dev.krtirtho.spotube.core.ui.base.CheckBox import dev.krtirtho.spotube.core.ui.base.CheckBoxState -import dev.krtirtho.spotube.core.ui.base.TextField +import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GroupIconButton import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.TextField +import dev.krtirtho.spotube.core.ui.base.buttonShadow +import dev.krtirtho.spotube.core.ui.base.rememberButtonColors import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply @@ -171,12 +183,15 @@ fun TrackList( } val listState = rememberLazyListState() - val shouldLoadMore = remember { + val density = LocalDensity.current + val shouldLoadMore = remember(density) { derivedStateOf { - val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() - // Note: totalItemsCount includes headers/footers, so this safely triggers - // a few items before the absolute bottom without index-shifting bugs. - lastVisibleItem != null && lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 5 + val loadMoreThreshold = with(density) { 200.dp.toPx() } + val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" } + // Trigger when the bottom of the track card is close to/inside the viewport, + // which means the user has scrolled through (or is about to scroll through) + // all loaded tracks. + cardItem != null && cardItem.offset + cardItem.size <= listState.layoutInfo.viewportEndOffset + loadMoreThreshold } } @@ -187,7 +202,6 @@ fun TrackList( } val windowInfo = LocalWindowInfo.current - val density = LocalDensity.current val maxWidth = with(density) { windowInfo.containerSize.width.toDp() } val isCompact = maxWidth < CompactTrackListBreakpoint val isDesktop = remember { getPlatform().isDesktop() } @@ -333,90 +347,139 @@ fun TrackList( } } - itemsIndexed( - items = visibleTracks, - key = { _, indexedTrack -> "${indexedTrack.id}-${indexedTrack.title}-${indexedTrack.album?.title ?: ""}" }, - ) { displayedIndex, track -> - TrackListRow( - index = displayedIndex + 1, - track = track, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - isCurrentTrack = track.id == currentTrackId, - isCurrentTrackPlaying = isCurrentTrackPlaying, - isSelectionMode = isSelectionMode, - isSelected = selectedTrackIds.contains(track.id), - onTrackClick = { - if (isSelectionMode) { - selectedTrackIds = if (selectedTrackIds.contains(track.id)) { - selectedTrackIds - track.id - } else { - selectedTrackIds + track.id + item(key = "track-card") { + val colors = rememberButtonColors() + val trackListShape = MaterialTheme.shapes.large + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .then( + buttonShadow( + trackListShape, + pressed = false, + primary = false, + colors, + hovered = false + ) + ) + .clip(trackListShape) + .background(MaterialTheme.colorScheme.surfaceContainer, trackListShape) + .border(BorderStroke(0.5.dp, colors.border), trackListShape) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 12.dp) + ) { + visibleTracks.forEachIndexed { displayedIndex, track -> + if (displayedIndex > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) } - } else { - onTrackClick(track) + TrackListRow( + index = displayedIndex + 1, + track = track, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + isCurrentTrack = track.id == currentTrackId, + isCurrentTrackPlaying = isCurrentTrackPlaying, + isSelectionMode = isSelectionMode, + isSelected = selectedTrackIds.contains(track.id), + onTrackClick = { + if (isSelectionMode) { + selectedTrackIds = + if (selectedTrackIds.contains(track.id)) { + selectedTrackIds - track.id + } else { + selectedTrackIds + track.id + } + } else { + onTrackClick(track) + } + }, + onLongClick = { + if (!useDropdownForOptions && !isSelectionMode) { + isSelectionMode = true + selectedTrackIds = setOf(track.id) + } else if (!useDropdownForOptions) { + selectedTrackForOptions = track + } + }, + onSelectionToggle = { checked -> + isSelectionMode = true + selectedTrackIds = if (checked) { + selectedTrackIds + track.id + } else { + selectedTrackIds - track.id + } + }, + onTrackOptionsAction = { action -> + onTrackOptionsAction( + track, + action + ) + }, + trackOptionsState = trackOptionsState(track), + onShowOptionsClick = { selectedTrackForOptions = track }, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = { onArtistsOverflowClick(track) }, + ) } - }, - onLongClick = { - if (!useDropdownForOptions && !isSelectionMode) { - isSelectionMode = true - selectedTrackIds = setOf(track.id) - } else if (!useDropdownForOptions) { - selectedTrackForOptions = track + + if (isLoading && tracks.isEmpty()) { + repeat(ShimmerRowCount) { shimmerIndex -> + if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + ShimmerTrackListRow( + index = shimmerIndex + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + ) + } } - }, - onSelectionToggle = { checked -> - isSelectionMode = true - selectedTrackIds = if (checked) { - selectedTrackIds + track.id - } else { - selectedTrackIds - track.id + + if (error != null) { + if (visibleTracks.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + TrackListFeedbackRow( + message = error, + isError = true, + ) } - }, - onTrackOptionsAction = { action -> onTrackOptionsAction(track, action) }, - trackOptionsState = trackOptionsState(track), - onShowOptionsClick = { selectedTrackForOptions = track }, - onArtistClick = onArtistClick, - onAlbumClick = onAlbumClick, - onArtistsOverflowClick = { onArtistsOverflowClick(track) }, - ) - } - if (isLoading && tracks.isEmpty()) { - items(ShimmerRowCount) { shimmerIndex -> - ShimmerTrackListRow( - index = shimmerIndex + 1, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) - } - } + if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { + TrackListFeedbackRow("No tracks found") + } - if (error != null) { - item { - TrackListFeedbackRow( - message = error, - isError = true, - ) - } - } - - if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { - item { - TrackListFeedbackRow("No tracks found") - } - } - - if (isLoadingNextPage) { - item { - ShimmerTrackListRow( - index = visibleTracks.size + 1, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) + if (isLoadingNextPage) { + if (visibleTracks.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + ShimmerTrackListRow( + index = visibleTracks.size + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + ) + } + } } } @@ -469,6 +532,7 @@ private fun TrackListRow( onArtistsOverflowClick: () -> Unit, modifier: Modifier = Modifier, ) { + val rowColors = rememberButtonColors() val artworkInteractionSource = remember { MutableInteractionSource() } val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() val rowInteractionSource = remember { MutableInteractionSource() } @@ -483,14 +547,32 @@ private fun TrackListRow( Row( modifier = modifier .fillMaxWidth() - .clip(MaterialTheme.shapes.small) - .background(rowBackgroundColor) .hoverable(rowInteractionSource) .combinedClickable( onClick = onTrackClick, onLongClick = onLongClick, ) - .padding(horizontal = 8.dp, vertical = 6.dp), + .background(rowBackgroundColor) + .drawWithCache { + val highlightBrush = Brush.verticalGradient( + colors = listOf(rowColors.highlight, Color.Transparent), + startY = 0f, + endY = size.height * 0.5f, + ) + onDrawWithContent { + drawContent() + if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) { + drawRect( + brush = highlightBrush, + topLeft = Offset.Zero, + size = size, + ) + } + } + } + .clip(MaterialTheme.shapes.small) + .padding(horizontal = 8.dp, vertical = 6.dp) + .padding(end = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { @@ -587,16 +669,47 @@ private fun TrackListRow( } if (showAlbum && track.album != null) { - TextWithShimmer( - text = track.album!!.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .clickable { onAlbumClick(track.album!!) }, - ) + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier.weight(1f) + ) { + TextWithShimmer( + text = track.album!!.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .hoverable(interactionSource) + .clickable( + indication = null, + interactionSource = interactionSource + ) { onAlbumClick(track.album!!) } + // Hide ripple and show underline on hover/click + .then( + if (isHovered || isPressed) { + Modifier.drawWithCache { + val underlineHeight = 1.dp.toPx() + onDrawWithContent { + drawContent() + drawLine( + color = colorScheme.primary, + start = Offset(0f, size.height - underlineHeight), + end = Offset(size.width, size.height - underlineHeight), + strokeWidth = underlineHeight, + ) + } + } + } else { + Modifier + } + ) + ) + } } TextWithShimmer( @@ -615,7 +728,7 @@ private fun TrackListRow( onAlbumClick = { track.album?.let { onAlbumClick(it) } }, ) } else { - IconButton(onClick = onShowOptionsClick) { + GhostIconButton(onClick = onShowOptionsClick) { Icon( imageVector = Iconsax.Iconsax3DotsMore, contentDescription = "Track options", @@ -637,15 +750,35 @@ private fun ArtistChips( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - visibleArtists.forEach { artist -> - TextWithShimmer( - text = artist.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickable { onArtistClick(artist) }, - ) + visibleArtists.forEachIndexed { index, artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + + TextWithShimmer( + text = artist.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null, + modifier = Modifier + .clickable( + indication = null, + interactionSource = interactionSource + ) { onArtistClick(artist) } + , + ) + if (index < visibleArtists.size - 1) + Text( + ",", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } } val remaining = artists.size - visibleArtists.size diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index dfbbbc42..ee10832f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -86,7 +87,7 @@ fun TrackOptions( onAlbumClick = onAlbumClick, ), trigger = { onClick -> - IconButton(onClick = onClick) { + GhostIconButton(onClick = onClick) { Icon( imageVector = Iconsax.Iconsax3DotsMore, contentDescription = "Track options",