feat: add GhostIconButton component and enhance button interactions in TrackList

This commit is contained in:
Kingkor Roy Tirtho 2026-07-03 22:21:06 +06:00
parent 6d8b9a575f
commit cb27514b9b
3 changed files with 289 additions and 116 deletions

View File

@ -237,6 +237,7 @@ fun OutlineButton(
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, shape: androidx.compose.ui.graphics.Shape = ButtonShape,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp), contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
hoverOnly: Boolean = false,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = rememberButtonColors() val colors = rememberButtonColors()
@ -252,17 +253,27 @@ fun OutlineButton(
.defaultMinSize(minHeight = ButtonMinHeight) .defaultMinSize(minHeight = ButtonMinHeight)
.hoverable(interactionSource = interactionSource, enabled = enabled) .hoverable(interactionSource = interactionSource, enabled = enabled)
.graphicsLayer { translationY = lift.toPx() } .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) .clip(shape)
.background(gradient, shape) .then(
.border(BorderStroke(0.5.dp, border), shape) if (hoverOnly && !isHovered) Modifier else Modifier.background(gradient, shape)
.border(BorderStroke(0.5.dp, border), shape)
)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.drawWithCache { .then(if (hoverOnly && !isHovered) Modifier else Modifier.drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(colors.highlight, Color.Transparent), colors = listOf(colors.highlight, Color.Transparent),
startY = 0f, startY = 0f,
@ -276,7 +287,7 @@ fun OutlineButton(
size = size, size = size,
) )
} }
} })
.padding(contentPadding), .padding(contentPadding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -427,6 +438,26 @@ fun IconButton(
content() 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 @Composable
fun PrimaryIconButton( fun PrimaryIconButton(
@ -579,7 +610,15 @@ fun ButtonGroup(
Surface( Surface(
modifier = modifier modifier = modifier
.defaultMinSize(minHeight = ButtonMinHeight) .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, shape = GroupShape,
color = Color.Transparent, color = Color.Transparent,
border = BorderStroke(0.5.dp, colors.border), border = BorderStroke(0.5.dp, colors.border),

View File

@ -21,13 +21,16 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.hoverable import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -58,11 +62,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextAlign 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.ButtonGroupDivider
import dev.krtirtho.spotube.core.ui.base.CheckBox import dev.krtirtho.spotube.core.ui.base.CheckBox
import dev.krtirtho.spotube.core.ui.base.CheckBoxState 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.GroupIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton 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.SkeletonTree
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
@ -171,12 +183,15 @@ fun TrackList(
} }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val shouldLoadMore = remember { val density = LocalDensity.current
val shouldLoadMore = remember(density) {
derivedStateOf { derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() val loadMoreThreshold = with(density) { 200.dp.toPx() }
// Note: totalItemsCount includes headers/footers, so this safely triggers val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" }
// a few items before the absolute bottom without index-shifting bugs. // Trigger when the bottom of the track card is close to/inside the viewport,
lastVisibleItem != null && lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 5 // 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 windowInfo = LocalWindowInfo.current
val density = LocalDensity.current
val maxWidth = with(density) { windowInfo.containerSize.width.toDp() } val maxWidth = with(density) { windowInfo.containerSize.width.toDp() }
val isCompact = maxWidth < CompactTrackListBreakpoint val isCompact = maxWidth < CompactTrackListBreakpoint
val isDesktop = remember { getPlatform().isDesktop() } val isDesktop = remember { getPlatform().isDesktop() }
@ -333,90 +347,139 @@ fun TrackList(
} }
} }
itemsIndexed( item(key = "track-card") {
items = visibleTracks, val colors = rememberButtonColors()
key = { _, indexedTrack -> "${indexedTrack.id}-${indexedTrack.title}-${indexedTrack.album?.title ?: ""}" }, val trackListShape = MaterialTheme.shapes.large
) { displayedIndex, track ->
TrackListRow( Box(
index = displayedIndex + 1, modifier = Modifier
track = track, .fillMaxWidth()
showIndex = showIndex, .padding(vertical = 8.dp)
showAlbum = showAlbum, .then(
useDropdownForOptions = useDropdownForOptions, buttonShadow(
isCurrentTrack = track.id == currentTrackId, trackListShape,
isCurrentTrackPlaying = isCurrentTrackPlaying, pressed = false,
isSelectionMode = isSelectionMode, primary = false,
isSelected = selectedTrackIds.contains(track.id), colors,
onTrackClick = { hovered = false
if (isSelectionMode) { )
selectedTrackIds = if (selectedTrackIds.contains(track.id)) { )
selectedTrackIds - track.id .clip(trackListShape)
} else { .background(MaterialTheme.colorScheme.surfaceContainer, trackListShape)
selectedTrackIds + track.id .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 { TrackListRow(
onTrackClick(track) 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 (isLoading && tracks.isEmpty()) {
if (!useDropdownForOptions && !isSelectionMode) { repeat(ShimmerRowCount) { shimmerIndex ->
isSelectionMode = true if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
selectedTrackIds = setOf(track.id) HorizontalDivider(
} else if (!useDropdownForOptions) { modifier = Modifier.padding(horizontal = 8.dp),
selectedTrackForOptions = track color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
} }
},
onSelectionToggle = { checked -> if (error != null) {
isSelectionMode = true if (visibleTracks.isNotEmpty()) {
selectedTrackIds = if (checked) { HorizontalDivider(
selectedTrackIds + track.id modifier = Modifier.padding(horizontal = 8.dp),
} else { color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
selectedTrackIds - track.id )
}
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()) { if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
items(ShimmerRowCount) { shimmerIndex -> TrackListFeedbackRow("No tracks found")
ShimmerTrackListRow( }
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
if (error != null) { if (isLoadingNextPage) {
item { if (visibleTracks.isNotEmpty()) {
TrackListFeedbackRow( HorizontalDivider(
message = error, modifier = Modifier.padding(horizontal = 8.dp),
isError = true, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
) )
} }
} ShimmerTrackListRow(
index = visibleTracks.size + 1,
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { showIndex = showIndex,
item { showAlbum = showAlbum,
TrackListFeedbackRow("No tracks found") useDropdownForOptions = useDropdownForOptions,
} )
} }
}
if (isLoadingNextPage) {
item {
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
} }
} }
@ -469,6 +532,7 @@ private fun TrackListRow(
onArtistsOverflowClick: () -> Unit, onArtistsOverflowClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val rowColors = rememberButtonColors()
val artworkInteractionSource = remember { MutableInteractionSource() } val artworkInteractionSource = remember { MutableInteractionSource() }
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
val rowInteractionSource = remember { MutableInteractionSource() } val rowInteractionSource = remember { MutableInteractionSource() }
@ -483,14 +547,32 @@ private fun TrackListRow(
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(rowBackgroundColor)
.hoverable(rowInteractionSource) .hoverable(rowInteractionSource)
.combinedClickable( .combinedClickable(
onClick = onTrackClick, onClick = onTrackClick,
onLongClick = onLongClick, 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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
@ -587,16 +669,47 @@ private fun TrackListRow(
} }
if (showAlbum && track.album != null) { if (showAlbum && track.album != null) {
TextWithShimmer( val interactionSource = remember { MutableInteractionSource() }
text = track.album!!.title, val isHovered by interactionSource.collectIsHoveredAsState()
style = MaterialTheme.typography.bodyMedium, val isPressed by interactionSource.collectIsPressedAsState()
color = MaterialTheme.colorScheme.primary, val colorScheme = MaterialTheme.colorScheme
maxLines = 1,
overflow = TextOverflow.Ellipsis, Row(
modifier = Modifier modifier = Modifier.weight(1f)
.weight(1f) ) {
.clickable { onAlbumClick(track.album!!) }, 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( TextWithShimmer(
@ -615,7 +728,7 @@ private fun TrackListRow(
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } },
) )
} else { } else {
IconButton(onClick = onShowOptionsClick) { GhostIconButton(onClick = onShowOptionsClick) {
Icon( Icon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Track options", contentDescription = "Track options",
@ -637,15 +750,35 @@ private fun ArtistChips(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
visibleArtists.forEach { artist -> visibleArtists.forEachIndexed { index, artist ->
TextWithShimmer( Row(
text = artist.name, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.bodySmall, ) {
color = MaterialTheme.colorScheme.primary, val interactionSource = remember { MutableInteractionSource() }
maxLines = 1, val isHovered by interactionSource.collectIsHoveredAsState()
overflow = TextOverflow.Ellipsis, val isPressed by interactionSource.collectIsPressedAsState()
modifier = Modifier.clickable { onArtistClick(artist) },
) 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 val remaining = artists.size - visibleArtists.size

View File

@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.base.IconButton
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@ -86,7 +87,7 @@ fun TrackOptions(
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
), ),
trigger = { onClick -> trigger = { onClick ->
IconButton(onClick = onClick) { GhostIconButton(onClick = onClick) {
Icon( Icon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Track options", contentDescription = "Track options",