diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt
new file mode 100644
index 00000000..4969bc4b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.core.ui.base
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun ListRowTile(
+ selected: Boolean = false,
+ enabled: Boolean = true,
+ onClick: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+ leading: @Composable (() -> Unit)? = null,
+ title: @Composable (() -> Unit)? = null,
+ subtitle: @Composable (() -> Unit)? = null,
+ trailing: @Composable (() -> Unit)? = null,
+) {
+ val rowTheme = LocalBaseUITheme.current.listRowTile
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val isHovered by interactionSource.collectIsHoveredAsState()
+ val isFocused by interactionSource.collectIsFocusedAsState()
+
+ val background = when {
+ isPressed -> rowTheme.background.pressed
+ isHovered -> rowTheme.background.hovered
+ isFocused -> rowTheme.background.focused
+ selected -> rowTheme.background.selected
+ else -> rowTheme.background.normal
+ }
+ val shape = when {
+ isPressed -> rowTheme.shape.pressed
+ isHovered -> rowTheme.shape.hovered
+ isFocused -> rowTheme.shape.focused
+ selected -> rowTheme.shape.selected
+ else -> rowTheme.shape.normal
+ }
+ val borderDef = when {
+ isPressed -> rowTheme.border.pressed
+ isHovered -> rowTheme.border.hovered
+ isFocused -> rowTheme.border.focused
+ selected -> rowTheme.border.selected
+ else -> rowTheme.border.normal
+ }
+ val shadowDef = when {
+ isPressed -> rowTheme.shadow.pressed
+ isHovered -> rowTheme.shadow.hovered
+ isFocused -> rowTheme.shadow.focused
+ selected -> rowTheme.shadow.selected
+ else -> rowTheme.shadow.normal
+ }
+
+ val mod = modifier
+ .fillMaxWidth()
+ .shadow(shadowDef.elevation, shape, shadowDef.clip, shadowDef.ambientColor, shadowDef.spotColor)
+ .clip(shape)
+ .border(borderDef.width, borderDef.color, shape)
+ .background(background, shape)
+ .then(
+ if (onClick != null) {
+ Modifier.clickable(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick,
+ )
+ } else Modifier
+ )
+ .padding(rowTheme.padding)
+
+ Row(
+ modifier = mod,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (leading != null) {
+ leading()
+ Spacer(Modifier.width(12.dp))
+ }
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ if (title != null) {
+ title()
+ }
+ if (subtitle != null) {
+ Spacer(Modifier.width(2.dp))
+ subtitle()
+ }
+ }
+ if (trailing != null) {
+ Spacer(Modifier.width(12.dp))
+ trailing()
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt
index 4f2d5abd..83d7ca59 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt
@@ -36,12 +36,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.FilledIconButton
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -49,7 +45,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -61,10 +56,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
-import compose.icons.FeatherIcons
-import compose.icons.feathericons.Play
-import compose.icons.feathericons.PlusSquare
-import compose.icons.feathericons.User
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
@@ -76,6 +67,10 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
+import dev.krtirtho.spotube.core.ui.base.PrimaryButton
+import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
+import dev.krtirtho.spotube.core.ui.base.SecondaryButton
+import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard
@@ -89,6 +84,10 @@ import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
+import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
+import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.compose.koinInject
@@ -349,7 +348,7 @@ private fun ArtistErrorContent(
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
)
- Button(onClick = onRetry) {
+ PrimaryButton(onClick = onRetry) {
Text("Retry")
}
}
@@ -452,7 +451,7 @@ private fun ArtistAvatar(
)
} else {
Icon(
- imageVector = FeatherIcons.User,
+ imageVector = Iconsax.User,
contentDescription = artist.name,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(size * 0.4f),
@@ -516,11 +515,11 @@ private fun ArtistHeaderActions(
verticalAlignment = Alignment.CenterVertically,
) {
if (isSaved) {
- FilledTonalButton(onClick = onFollowClick) {
+ SecondaryButton(onClick = onFollowClick) {
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
}
} else {
- Button(onClick = onFollowClick) {
+ PrimaryButton(onClick = onFollowClick) {
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
}
}
@@ -546,15 +545,15 @@ private fun TopTracksHeader(
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- FilledIconButton(onClick = onPlay) {
+ PrimaryIconButton(onClick = onPlay) {
Icon(
- imageVector = FeatherIcons.Play,
+ imageVector = Iconsax.IconsaxPlay,
contentDescription = "Play top tracks",
)
}
- FilledTonalIconButton(onClick = onAddToQueue) {
+ SecondaryIconButton(onClick = onAddToQueue) {
Icon(
- imageVector = FeatherIcons.PlusSquare,
+ imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add top tracks to queue",
)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt
index 529af12f..9262e8fe 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt
@@ -20,6 +20,8 @@ package dev.krtirtho.spotube.modules.shell.player_queue
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -60,7 +62,10 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.rememberLogger
+import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
+import dev.krtirtho.spotube.core.ui.base.IconButton
+import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.base.TextField
@@ -191,9 +196,9 @@ fun PlayerQueueContent(
singleLine = true,
modifier = Modifier.weight(1f),
)
- SecondaryIconButton(
+ IconButton(
onClick = viewModel::clearQueue,
- theme = LocalBaseUITheme.current.iconButtons.secondary.copyShape(MaterialTheme.shapes.small),
+ theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
) {
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
}
@@ -206,24 +211,23 @@ fun PlayerQueueContent(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- state = lazyListState,
- contentPadding = PaddingValues(bottom = 8.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- items(displayList, key = { item -> item.id }) { item ->
- ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
- val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
- QueueItemRow(
- item = item,
- isDragging = isDragging,
- elevation = elevation,
- reorderScope = if (isFiltered) null else this,
- onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
- onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
- onDragStopped = ::finalizeReorder,
- )
+ Card {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = lazyListState,
+ contentPadding = PaddingValues(bottom = 8.dp),
+ ) {
+ items(displayList, key = { item -> item.id }) { item ->
+ ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
+ val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
+ QueueItemRow(
+ item = item,
+ reorderScope = if (isFiltered) null else this,
+ onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
+ onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
+ onDragStopped = ::finalizeReorder,
+ )
+ }
}
}
}
@@ -235,8 +239,6 @@ fun PlayerQueueContent(
@Composable
private fun QueueItemRow(
item: QueueItemUi,
- isDragging: Boolean,
- elevation: androidx.compose.ui.unit.Dp,
reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?,
onPlayClick: () -> Unit,
onRemoveClick: () -> Unit,
@@ -244,92 +246,78 @@ private fun QueueItemRow(
) {
var showMenu by remember { mutableStateOf(false) }
- val rowColor = if (item.isCurrent) {
- MaterialTheme.colorScheme.secondaryContainer
- } else {
- MaterialTheme.colorScheme.surfaceContainerHigh
- }
-
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .clip(MaterialTheme.shapes.medium)
- .highlight(LocalBaseUITheme.current.buttons.secondary.colors.normal.highlight)
- .clickable(onClick = onPlayClick),
- shadowElevation = elevation,
- tonalElevation = if (item.isCurrent) 2.dp else 0.dp,
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(72.dp)
- .background(rowColor)
- .padding(horizontal = 12.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- Iconsax.IconsaxDragHandle,
- contentDescription = if (reorderScope != null) "Reorder" else null,
+ ListRowTile(
+ onClick = onPlayClick,
+ selected = item.isCurrent,
+ modifier = Modifier,
+ leading = {
+ Row(
modifier = Modifier
- .size(24.dp)
- .then(
- if (reorderScope != null) {
- with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) }
- } else {
- Modifier
- },
- ),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
-
- Box(
- modifier = Modifier
- .size(48.dp)
- .clip(MaterialTheme.shapes.small)
- .background(MaterialTheme.colorScheme.surfaceVariant),
- contentAlignment = Alignment.Center,
+ .height(72.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
- if (item.imageUrl != null) {
- AsyncImage(
- model = item.imageUrl,
- contentDescription = null,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Crop,
- )
- } else {
- Text(
- text = "${item.originalIndex + 1}",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
+ Icon(
+ Iconsax.IconsaxDragHandle,
+ contentDescription = if (reorderScope != null) "Reorder" else null,
+ modifier = Modifier
+ .size(24.dp)
+ .then(
+ if (reorderScope != null) {
+ with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) }
+ } else {
+ Modifier
+ },
+ ),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(MaterialTheme.shapes.small)
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (item.imageUrl != null) {
+ AsyncImage(
+ model = item.imageUrl,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ } else {
+ Text(
+ text = "${item.originalIndex + 1}",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
}
-
- Spacer(modifier = Modifier.width(12.dp))
-
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = item.title,
- style = MaterialTheme.typography.bodyLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = if (item.isCurrent) {
- MaterialTheme.colorScheme.onSecondaryContainer
- } else {
- MaterialTheme.colorScheme.onSurface
- },
- )
- Text(
- text = item.subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
-
- Spacer(modifier = Modifier.width(8.dp))
-
+ },
+ title = {
+ Text(
+ text = item.title,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (item.isCurrent) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ },
+ subtitle = {
+ Text(
+ text = item.subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ trailing = {
Text(
text = item.durationMs.toDurationString(),
style = MaterialTheme.typography.bodySmall,
@@ -366,7 +354,7 @@ private fun QueueItemRow(
}
}
}
- }
+ )
}
private fun QueueEntry.toQueueDisplayData(): Tuple4 {