Compare commits

..

6 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
a38b9e48cc fix: convert buildCurrentMediaItemList to a suspend function for coroutine support 2026-07-10 13:42:37 +06:00
Kingkor Roy Tirtho
2aae112cbe feat: add genre and featured browsing capabilities to MediaBrowseHelper 2026-07-10 13:31:04 +06:00
Kingkor Roy Tirtho
d53ac853a5 refactor: replace button components with themed variants and introduce ListRowTile 2026-07-10 13:06:25 +06:00
Kingkor Roy Tirtho
69a464dcd2 cd: fix version name conflict 2026-07-10 11:52:20 +06:00
Kingkor Roy Tirtho
941b333a79 refactor: update UI component states to use normal theme colors 2026-07-10 11:49:03 +06:00
Kingkor Roy Tirtho
f6308d0f8b cd: fix stuff 2026-07-10 11:06:53 +06:00
15 changed files with 595 additions and 225 deletions

View File

@ -25,6 +25,7 @@ env:
COMPOSE_WEBVIEW_REPO: kdroidFilter/ComposeNativeWebview
COMPOSE_WEBVIEW_COMMIT: 5aeb268a2c37ff4dbfb76924573344a7c4f763ce
GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin
VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin
jobs:
prepare-deps:
@ -46,6 +47,13 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
path: gradle-plugin
- name: Checkout vlcj-bundler
uses: actions/checkout@v4
with:
repository: ${{ env.VLCJ_BUNDLER_REPO }}
token: ${{ secrets.GITHUB_TOKEN }}
path: vlcj-bundler
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
@ -73,38 +81,30 @@ jobs:
chmod +x gradlew
./gradlew publishToMavenLocal
- name: Upload compose-webview artifacts
uses: actions/upload-artifact@v4
with:
name: maven-local-composewebview
path: ~/.m2/repository/io/github/kdroidfilter/
- name: Publish vlcj-bundler to mavenLocal
working-directory: vlcj-bundler
run: |
chmod +x gradlew
./gradlew publishToMavenLocal
- name: Upload gradle-plugin artifacts
- name: Upload maven local
uses: actions/upload-artifact@v4
with:
name: maven-local-gradle-plugin
path: ~/.m2/repository/dev/krtirtho/
name: maven-local
path: ~/.m2/repository/
build-android:
name: Android
needs: prepare-deps
runs-on: ubuntu-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps:
- uses: actions/checkout@v4
- name: Download compose-webview artifacts
- name: Download maven local
uses: actions/download-artifact@v4
with:
name: maven-local-composewebview
path: ~/.m2/repository/io/github/kdroidfilter/
- name: Download gradle-plugin artifacts
uses: actions/download-artifact@v4
with:
name: maven-local-gradle-plugin
path: ~/.m2/repository/dev/krtirtho/
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v4
@ -133,7 +133,7 @@ jobs:
EOF
- name: Assemble Release
run: ./gradlew :composeApp:assembleRelease ${{ env.GRADLE_FLAGS }}
run: ./gradlew :composeApp:assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
@ -151,22 +151,14 @@ jobs:
name: Linux Desktop
needs: prepare-deps
runs-on: ubuntu-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps:
- uses: actions/checkout@v4
- name: Download compose-webview artifacts
- name: Download maven local
uses: actions/download-artifact@v4
with:
name: maven-local-composewebview
path: ~/.m2/repository/io/github/kdroidfilter/
- name: Download gradle-plugin artifacts
uses: actions/download-artifact@v4
with:
name: maven-local-gradle-plugin
path: ~/.m2/repository/dev/krtirtho/
name: maven-local
path: ~/.m2/repository/
- name: Install packaging tools
run: |
@ -186,13 +178,13 @@ jobs:
run: chmod +x gradlew
- name: Package DEB
run: ./gradlew :composeApp:packageReleaseDeb ${{ env.GRADLE_FLAGS }}
run: ./gradlew :composeApp:packageReleaseDeb
- name: Package RPM
run: ./gradlew :composeApp:packageReleaseRpm ${{ env.GRADLE_FLAGS }}
run: ./gradlew :composeApp:packageReleaseRpm
- name: Package AppImage
run: ./gradlew :composeApp:packageReleaseAppImage ${{ env.GRADLE_FLAGS }}
run: ./gradlew :composeApp:packageReleaseAppImage
- name: Upload DEB
uses: actions/upload-artifact@v4
@ -216,22 +208,14 @@ jobs:
name: Windows Desktop
needs: prepare-deps
runs-on: windows-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps:
- uses: actions/checkout@v4
- name: Download compose-webview artifacts
- name: Download maven local
uses: actions/download-artifact@v4
with:
name: maven-local-composewebview
path: ~/.m2/repository/io/github/kdroidfilter/
- name: Download gradle-plugin artifacts
uses: actions/download-artifact@v4
with:
name: maven-local-gradle-plugin
path: ~/.m2/repository/dev/krtirtho/
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v4
@ -243,10 +227,10 @@ jobs:
uses: gradle/actions/setup-gradle@v4
- name: Package MSI
run: ./gradlew :composeApp:packageReleaseMsi ${{ env.GRADLE_FLAGS }}
run: ./gradlew :composeApp:packageReleaseMsi
- name: Package EXE
run: ./gradlew :composeApp:packageReleaseExe ${{ env.GRADLE_FLAGS }}
run: ./gradlew :composeApp:packageReleaseExe
- name: Upload MSI
uses: actions/upload-artifact@v4
@ -264,22 +248,14 @@ jobs:
name: macOS Desktop
needs: prepare-deps
runs-on: macos-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps:
- uses: actions/checkout@v4
- name: Download compose-webview artifacts
- name: Download maven local
uses: actions/download-artifact@v4
with:
name: maven-local-composewebview
path: ~/.m2/repository/io/github/kdroidfilter/
- name: Download gradle-plugin artifacts
uses: actions/download-artifact@v4
with:
name: maven-local-gradle-plugin
path: ~/.m2/repository/dev/krtirtho/
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v4
@ -294,7 +270,7 @@ jobs:
run: chmod +x gradlew
- name: Package DMG
run: ./gradlew :composeApp:packageReleaseDmg ${{ env.GRADLE_FLAGS }}
run: ./gradlew :composeApp:packageReleaseDmg
- name: Upload DMG
uses: actions/upload-artifact@v4

View File

@ -57,6 +57,8 @@ class MediaBrowseHelper(
companion object {
const val MEDIA_ID_ROOT = "root"
const val MEDIA_ID_BROWSE = "browse"
const val MEDIA_ID_BROWSE_GENRE = "browse:genre"
const val MEDIA_ID_BROWSE_FEATURED = "browse:featured"
const val MEDIA_ID_LIBRARY = "library"
const val MEDIA_ID_SAVED_PLAYLISTS = "library:playlists"
const val MEDIA_ID_SAVED_ALBUMS = "library:albums"
@ -93,6 +95,11 @@ class MediaBrowseHelper(
return when {
parentId == MEDIA_ID_ROOT -> buildRootChildren()
parentId == MEDIA_ID_BROWSE -> buildBrowseItems()
parentId == MEDIA_ID_BROWSE_FEATURED -> buildFeaturedItems()
parentId.startsWith("$MEDIA_ID_BROWSE_GENRE:") -> {
val genreId = parentId.removePrefix("$MEDIA_ID_BROWSE_GENRE:")
buildBrowseGenreItems(genreId)
}
parentId == MEDIA_ID_LIBRARY -> buildLibraryRootChildren()
parentId == MEDIA_ID_SAVED_PLAYLISTS -> buildSavedPlaylistsItems()
parentId == MEDIA_ID_SAVED_ALBUMS -> buildSavedAlbumsItems()
@ -214,7 +221,6 @@ class MediaBrowseHelper(
}
private fun buildRootChildren(): List<MediaItem> {
val gridExtras = Bundle().apply { putInt(CONTENT_STYLE_BROWSABLE, CONTENT_STYLE_GRID) }
return listOf(
MediaItem.Builder()
.setMediaId(MEDIA_ID_BROWSE)
@ -223,7 +229,6 @@ class MediaBrowseHelper(
.setTitle("Browse")
.setIsBrowsable(true)
.setIsPlayable(false)
.setExtras(gridExtras)
.build()
)
.build(),
@ -251,27 +256,67 @@ class MediaBrowseHelper(
}
private suspend fun buildBrowseItems(): List<MediaItem> {
val gridExtras = Bundle().apply { putInt(CONTENT_STYLE_BROWSABLE, CONTENT_STYLE_GRID) }
val items = mutableListOf<MediaItem>()
items.add(
MediaItem.Builder()
.setMediaId(MEDIA_ID_BROWSE_FEATURED)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Featured")
.setIsBrowsable(true)
.setIsPlayable(false)
.setExtras(gridExtras)
.build()
)
.build()
)
val genres = try {
homeScreenRepository.genres() ?: emptyList()
} catch (_: Exception) {
emptyList()
}
for (genre in genres) {
items.add(
MediaItem.Builder()
.setMediaId("$MEDIA_ID_BROWSE_GENRE:${genre.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(genre.name)
.setIsBrowsable(true)
.setIsPlayable(false)
.setExtras(gridExtras)
.build()
)
.build()
)
}
return items
}
private suspend fun buildFeaturedItems(): List<MediaItem> {
val featured = try {
homeScreenRepository.featuredItems() ?: emptyList()
} catch (_: Exception) {
emptyList()
}
return featured.mapNotNull { item -> browseItemToMediaItem(item) }
}
private suspend fun buildBrowseGenreItems(genreId: String): List<MediaItem> {
val sections = try {
homeScreenRepository.list()?.items ?: emptyList()
homeScreenRepository.list(genreId)?.items ?: emptyList()
} catch (_: Exception) {
emptyList()
}
val items = mutableListOf<MediaItem>()
if (featured.isNotEmpty()) {
for (item in featured) {
val mediaItem = browseItemToMediaItem(item) ?: continue
items.add(mediaItem.withGroupTitle("Featured"))
}
}
for (section in sections) {
for (item in section.items) {
val mediaItem = browseItemToMediaItem(

View File

@ -47,6 +47,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -372,7 +373,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
}
}
private fun buildCurrentMediaItemList(): List<Media3MediaItem> {
private suspend fun buildCurrentMediaItemList(): List<Media3MediaItem> = withContext(Dispatchers.Main) {
val count = audioPlayer.player.mediaItemCount
val items = mutableListOf<Media3MediaItem>()
for (i in 0 until count) {
@ -381,7 +382,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
items.add(item)
}
}
return items
items
}
companion object {

View File

@ -125,12 +125,12 @@ private fun resolveTextFieldState(
)
else -> ResolvedAutoCompleteTextFieldState(
theme.background.pressed,
theme.highlight.pressed,
theme.shape.pressed,
theme.border.pressed,
theme.shadow.pressed,
theme.foreground.pressed
theme.background.normal,
theme.highlight.normal,
theme.shape.normal,
theme.border.normal,
theme.shadow.normal,
theme.foreground.normal
)
}
}

View File

@ -106,11 +106,11 @@ private fun resolveButtonState(
)
else -> ResolvedButtonState(
style.colors.focused,
style.shape.focused,
style.shadow.focused,
style.border.focused,
style.padding.focused
style.colors.normal,
style.shape.normal,
style.shadow.normal,
style.border.normal,
style.padding.normal
)
}
}
@ -311,21 +311,28 @@ fun GhostIconButton(
val outlineStyle = baseTheme.iconButtons.outline
val ghostStyle = theme ?: BaseUITheme.ButtonStyle(
colors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
foreground = outlineStyle.colors.normal.foreground,
highlight = outlineStyle.colors.normal.highlight,
),
hovered = outlineStyle.colors.hovered,
pressed = outlineStyle.colors.pressed,
focused = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
foreground = outlineStyle.colors.focused.foreground,
highlight = outlineStyle.colors.focused.highlight,
foreground = outlineStyle.colors.normal.foreground,
highlight = outlineStyle.colors.normal.highlight,
),
),
shape = outlineStyle.shape,
shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
hovered = outlineStyle.shadow.hovered,
pressed = outlineStyle.shadow.pressed,
focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
),
border = BaseUITheme.InteractionState(
normal = BaseUITheme.Border(Color.Transparent, 0.dp),
hovered = outlineStyle.border.hovered,
pressed = outlineStyle.border.pressed,
focused = BaseUITheme.Border(Color.Transparent, 0.dp),

View File

@ -85,7 +85,7 @@ private fun resolveCheckBoxState(
return when {
isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
isHovered -> ResolvedCheckBoxState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
else -> ResolvedCheckBoxState(style.colors.focused, style.shape.focused, style.shadow.focused, style.border.focused)
else -> ResolvedCheckBoxState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal)
}
}

View File

@ -70,7 +70,7 @@ private fun resolveChipState(
return when {
isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed)
isHovered -> ResolvedChipState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered)
else -> ResolvedChipState(style.colors.focused, style.shape.focused, style.shadow.focused, style.border.focused, style.padding.focused)
else -> ResolvedChipState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal, style.padding.normal)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
}

View File

@ -95,22 +95,22 @@ fun Slider(
val stateColor = when {
isPressed -> sliderTheme.trackActiveColor.pressed
isHovered -> sliderTheme.trackActiveColor.hovered
else -> sliderTheme.trackActiveColor.focused
else -> sliderTheme.trackActiveColor.normal
}
val inactiveColor = when {
isPressed -> sliderTheme.trackInactiveColor.pressed
isHovered -> sliderTheme.trackInactiveColor.hovered
else -> sliderTheme.trackInactiveColor.focused
else -> sliderTheme.trackInactiveColor.normal
}
val thumbColor = when {
isPressed -> sliderTheme.thumbColor.pressed
isHovered -> sliderTheme.thumbColor.hovered
else -> sliderTheme.thumbColor.focused
else -> sliderTheme.thumbColor.normal
}
val thumbShadowState = when {
isPressed -> sliderTheme.thumbShadow.pressed
isHovered -> sliderTheme.thumbShadow.hovered
else -> sliderTheme.thumbShadow.focused
else -> sliderTheme.thumbShadow.normal
}
val trackActiveColor = if (enabled) stateColor else stateColor.copy(alpha = 0.38f)

View File

@ -81,7 +81,7 @@ private fun resolveTextFieldState(
return when {
isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused)
isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered)
else -> ResolvedTextFieldState(theme.background.pressed, theme.highlight.pressed, theme.shape.pressed, theme.border.pressed, theme.shadow.pressed, theme.foreground.pressed)
else -> ResolvedTextFieldState(theme.background.normal, theme.highlight.normal, theme.shape.normal, theme.border.normal, theme.shadow.normal, theme.foreground.normal)
}
}

View File

@ -54,19 +54,21 @@ data class BaseUITheme(
@Stable
data class InteractionState<T>(
val normal: T,
val hovered: T,
val pressed: T,
val focused: T,
) {
companion object {
fun <T> fromSingleValue(value: T): InteractionState<T> {
return InteractionState(value, value, value)
return InteractionState(value, value, value, value)
}
}
}
@Stable
data class AdvancedInteractionState<T>(
val normal: T,
val hovered: T,
val pressed: T,
val focused: T,
@ -75,7 +77,7 @@ data class BaseUITheme(
) {
companion object {
fun <T> fromSingleValue(value: T): AdvancedInteractionState<T> {
return AdvancedInteractionState(value, value, value, value, value)
return AdvancedInteractionState(value, value, value, value, value, value)
}
}
}
@ -200,23 +202,30 @@ fun rememberBaseUITheme(): BaseUITheme {
val defaultShape = RoundedCornerShape(14.dp)
val defaultShapeState =
BaseUITheme.InteractionState<Shape>(defaultShape, defaultShape, defaultShape)
BaseUITheme.InteractionState<Shape>(defaultShape, defaultShape, defaultShape, defaultShape)
val noBorder = BaseUITheme.Border(Color.Transparent, 0.dp)
val noBorderState = BaseUITheme.InteractionState(noBorder, noBorder, noBorder)
val noBorderState = BaseUITheme.InteractionState(noBorder, noBorder, noBorder, noBorder)
val noShadow = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent)
val noShadowState = BaseUITheme.InteractionState(noShadow, noShadow, noShadow)
val noShadowState = BaseUITheme.InteractionState(noShadow, noShadow, noShadow, noShadow)
val defaultButtonPadding = BaseUITheme.InteractionState(
PaddingValues(horizontal = 20.dp, vertical = 10.dp),
PaddingValues(horizontal = 20.dp, vertical = 10.dp),
PaddingValues(horizontal = 20.dp, vertical = 10.dp),
PaddingValues(horizontal = 20.dp, vertical = 10.dp),
)
val iconButtonPadding = BaseUITheme.InteractionState(
PaddingValues(8.dp),
PaddingValues(8.dp),
PaddingValues(8.dp),
PaddingValues(8.dp),
)
val outlineColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
foreground = scheme.onSurface,
highlight = highlight,
),
hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
foreground = scheme.onSurface,
@ -234,6 +243,12 @@ fun rememberBaseUITheme(): BaseUITheme {
),
)
val outlineShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
6.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
hovered = BaseUITheme.Shadow(
9.dp,
true,
@ -254,12 +269,18 @@ fun rememberBaseUITheme(): BaseUITheme {
),
)
val outlineBorder = BaseUITheme.InteractionState(
normal = BaseUITheme.Border(border, 0.5.dp),
hovered = BaseUITheme.Border(border, 0.5.dp),
pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp),
focused = BaseUITheme.Border(border, 0.5.dp),
)
val primaryColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
foreground = scheme.onPrimary,
highlight = Color.White.copy(alpha = 0.25f),
),
hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
foreground = scheme.onPrimary,
@ -282,6 +303,12 @@ fun rememberBaseUITheme(): BaseUITheme {
),
)
val primaryShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
6.dp,
true,
scheme.primary.copy(alpha = 0.35f),
scheme.primary.copy(alpha = 0.4f)
),
hovered = BaseUITheme.Shadow(
9.dp,
true,
@ -302,12 +329,23 @@ fun rememberBaseUITheme(): BaseUITheme {
),
)
val primaryBorder = BaseUITheme.InteractionState(
normal = BaseUITheme.Border(scheme.primary, 0.5.dp),
hovered = BaseUITheme.Border(scheme.primary, 0.5.dp),
pressed = BaseUITheme.Border(scheme.primary, 0.5.dp),
focused = BaseUITheme.Border(scheme.primary, 0.5.dp),
)
val secondaryColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
secondaryContainer,
secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f)
)
),
foreground = scheme.onSecondaryContainer,
highlight = secondaryHighlight,
),
hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
@ -340,6 +378,12 @@ fun rememberBaseUITheme(): BaseUITheme {
),
)
val secondaryShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
6.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
hovered = BaseUITheme.Shadow(
9.dp,
true,
@ -360,12 +404,18 @@ fun rememberBaseUITheme(): BaseUITheme {
),
)
val secondaryBorder = BaseUITheme.InteractionState(
normal = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
hovered = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
pressed = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
focused = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
)
val ghostColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
foreground = scheme.onSurface,
highlight = Color.Transparent,
),
hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
@ -447,6 +497,7 @@ fun rememberBaseUITheme(): BaseUITheme {
)
val textFieldForeground = BaseUITheme.InteractionState(
normal = scheme.onSurface,
hovered = scheme.onSurface,
pressed = scheme.onSurface,
focused = scheme.onSurface,
@ -454,17 +505,18 @@ fun rememberBaseUITheme(): BaseUITheme {
val checkBoxShape = RoundedCornerShape(6.dp)
val checkBoxShapeState =
BaseUITheme.InteractionState<Shape>(checkBoxShape, checkBoxShape, checkBoxShape)
BaseUITheme.InteractionState<Shape>(checkBoxShape, checkBoxShape, checkBoxShape, checkBoxShape)
val noPadding =
BaseUITheme.InteractionState(PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp))
BaseUITheme.InteractionState(PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp))
val chipTabShape = RoundedCornerShape(10.dp)
val chipTabShapeState =
BaseUITheme.InteractionState<Shape>(chipTabShape, chipTabShape, chipTabShape)
BaseUITheme.InteractionState<Shape>(chipTabShape, chipTabShape, chipTabShape, chipTabShape)
val chipTabPadding = BaseUITheme.InteractionState(
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
)
return BaseUITheme(
@ -482,26 +534,36 @@ fun rememberBaseUITheme(): BaseUITheme {
),
textField = TextFieldTheme(
background = BaseUITheme.InteractionState(
normal = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
pressed = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
),
highlight = BaseUITheme.InteractionState(
normal = highlight,
hovered = highlight,
pressed = highlight,
focused = highlight,
),
shape = BaseUITheme.InteractionState(
normal = defaultShape,
hovered = defaultShape,
pressed = defaultShape,
focused = defaultShape,
),
border = BaseUITheme.InteractionState(
normal = BaseUITheme.Border(border.copy(alpha = 0.85f), 0.5.dp),
hovered = BaseUITheme.Border(border.copy(alpha = 0.85f), 0.5.dp),
pressed = BaseUITheme.Border(border, 0.5.dp),
focused = BaseUITheme.Border(scheme.primary, 0.5.dp),
),
shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
9.dp,
true,
shadowColor.copy(alpha = 0.22f),
shadowColor.copy(alpha = 0.26f)
),
hovered = BaseUITheme.Shadow(
9.dp,
true,
@ -540,21 +602,30 @@ fun rememberBaseUITheme(): BaseUITheme {
),
slider = SliderTheme(
trackActiveColor = BaseUITheme.InteractionState(
normal = scheme.primary,
hovered = scheme.primary,
pressed = scheme.primary,
focused = scheme.primary,
),
trackInactiveColor = BaseUITheme.InteractionState(
normal = border.copy(alpha = 0.6f),
hovered = border.copy(alpha = 0.6f),
pressed = border.copy(alpha = 0.6f),
focused = border.copy(alpha = 0.6f),
),
thumbColor = BaseUITheme.InteractionState(
normal = scheme.primary,
hovered = scheme.primary,
pressed = scheme.primary,
focused = scheme.primary,
),
thumbShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
5.dp,
true,
scheme.primary.copy(alpha = 0.35f),
scheme.primary.copy(alpha = 0.4f)
),
hovered = BaseUITheme.Shadow(
8.dp,
true,
@ -582,6 +653,12 @@ fun rememberBaseUITheme(): BaseUITheme {
colors = primaryColors,
shape = checkBoxShapeState,
shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
6.dp,
true,
scheme.primary.copy(alpha = 0.3f),
scheme.primary.copy(alpha = 0.35f)
),
hovered = BaseUITheme.Shadow(
8.dp,
true,
@ -608,6 +685,12 @@ fun rememberBaseUITheme(): BaseUITheme {
colors = outlineColors,
shape = checkBoxShapeState,
shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
3.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
hovered = BaseUITheme.Shadow(
5.dp,
true,
@ -662,6 +745,7 @@ fun rememberBaseUITheme(): BaseUITheme {
),
listRowTile = BaseUITheme.ListRowTheme(
background = BaseUITheme.AdvancedInteractionState(
normal = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
pressed = Brush.verticalGradient(listOf(containerPressed, containerPressed)),
focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
@ -679,6 +763,7 @@ fun rememberBaseUITheme(): BaseUITheme {
),
),
shape = BaseUITheme.AdvancedInteractionState(
normal = RoundedCornerShape(8.dp),
hovered = RoundedCornerShape(8.dp),
pressed = RoundedCornerShape(8.dp),
focused = RoundedCornerShape(8.dp),
@ -686,6 +771,7 @@ fun rememberBaseUITheme(): BaseUITheme {
disabled = RoundedCornerShape(8.dp),
),
border = BaseUITheme.AdvancedInteractionState(
normal = BaseUITheme.Border(Color.Transparent, 0.dp),
hovered = BaseUITheme.Border(Color.Transparent, 0.dp),
pressed = BaseUITheme.Border(Color.Transparent, 0.dp),
focused = BaseUITheme.Border(Color.Transparent, 0.dp),
@ -693,6 +779,7 @@ fun rememberBaseUITheme(): BaseUITheme {
disabled = BaseUITheme.Border(Color.Transparent, 0.dp),
),
shadow = BaseUITheme.AdvancedInteractionState(
normal = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
hovered = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
pressed = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
@ -701,6 +788,7 @@ fun rememberBaseUITheme(): BaseUITheme {
),
padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
textStyle = BaseUITheme.AdvancedInteractionState(
normal = TextStyle.Default,
hovered = TextStyle.Default,
pressed = TextStyle.Default,
focused = TextStyle.Default,
@ -708,6 +796,7 @@ fun rememberBaseUITheme(): BaseUITheme {
disabled = TextStyle.Default,
),
foreground = BaseUITheme.AdvancedInteractionState(
normal = scheme.onSurface,
hovered = scheme.onSurface,
pressed = scheme.onSurface,
focused = scheme.onSurface,
@ -766,6 +855,7 @@ fun invertedButtonStyle(): BaseUITheme.ButtonStyle {
return BaseUITheme.ButtonStyle(
colors = BaseUITheme.InteractionState(
normal = invertedColors,
hovered = invertedColors,
pressed = pressedColors,
focused = invertedColors,

View File

@ -562,7 +562,7 @@ private fun TrackListRow(
isSelected -> rowTheme.background.selected
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
isCurrentTrack -> rowTheme.background.focused
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
else -> rowTheme.background.normal
}
val highlightColor = Color.White.copy(
alpha = if (isLight) 0.9f else 0.06f

View File

@ -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",
)
}

View File

@ -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
@ -53,22 +55,28 @@ 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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import compose.icons.FeatherIcons
import compose.icons.feathericons.MoreVertical
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
import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.core.ui.base.highlight
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem
@ -127,7 +135,7 @@ fun PlayerQueueContent(
val filtered = if (isFiltered) {
sourceItems.filter { item ->
item.title.lowercase().contains(normalizedFilter) ||
item.subtitle.lowercase().contains(normalizedFilter)
item.subtitle.lowercase().contains(normalizedFilter)
}
} else {
sourceItems
@ -188,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")
}
@ -203,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,
)
}
}
}
}
@ -232,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,
@ -241,91 +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)
.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(
FeatherIcons.MoreVertical,
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,
@ -340,7 +332,7 @@ private fun QueueItemRow(
modifier = Modifier.size(36.dp),
) {
Icon(
FeatherIcons.MoreVertical,
Iconsax.Iconsax3DotsMore,
contentDescription = "More options",
modifier = Modifier.size(18.dp),
)
@ -362,7 +354,7 @@ private fun QueueItemRow(
}
}
}
}
)
}
private fun QueueEntry.toQueueDisplayData(): Tuple4<String, String, Long, String?> {

View File

@ -0,0 +1,128 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxDragHandle: ImageVector
get() {
if (_IconsaxDragHandle != null) {
return _IconsaxDragHandle!!
}
_IconsaxDragHandle = ImageVector.Builder(
name = "IconsaxDragHandle",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(8f, 4f)
curveTo(6.9f, 4f, 6f, 4.9f, 6f, 6f)
curveTo(6f, 7.1f, 6.9f, 8f, 8f, 8f)
curveTo(9.1f, 8f, 10f, 7.1f, 10f, 6f)
curveTo(10f, 4.9f, 9.1f, 4f, 8f, 4f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(16f, 4f)
curveTo(14.9f, 4f, 14f, 4.9f, 14f, 6f)
curveTo(14f, 7.1f, 14.9f, 8f, 16f, 8f)
curveTo(17.1f, 8f, 18f, 7.1f, 18f, 6f)
curveTo(18f, 4.9f, 17.1f, 4f, 16f, 4f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(8f, 10f)
curveTo(6.9f, 10f, 6f, 10.9f, 6f, 12f)
curveTo(6f, 13.1f, 6.9f, 14f, 8f, 14f)
curveTo(9.1f, 14f, 10f, 13.1f, 10f, 12f)
curveTo(10f, 10.9f, 9.1f, 10f, 8f, 10f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(16f, 10f)
curveTo(14.9f, 10f, 14f, 10.9f, 14f, 12f)
curveTo(14f, 13.1f, 14.9f, 14f, 16f, 14f)
curveTo(17.1f, 14f, 18f, 13.1f, 18f, 12f)
curveTo(18f, 10.9f, 17.1f, 10f, 16f, 10f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(8f, 16f)
curveTo(6.9f, 16f, 6f, 16.9f, 6f, 18f)
curveTo(6f, 19.1f, 6.9f, 20f, 8f, 20f)
curveTo(9.1f, 20f, 10f, 19.1f, 10f, 18f)
curveTo(10f, 16.9f, 9.1f, 16f, 8f, 16f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(16f, 16f)
curveTo(14.9f, 16f, 14f, 16.9f, 14f, 18f)
curveTo(14f, 19.1f, 14.9f, 20f, 16f, 20f)
curveTo(17.1f, 20f, 18f, 19.1f, 18f, 18f)
curveTo(18f, 16.9f, 17.1f, 16f, 16f, 16f)
close()
}
}
}.build()
return _IconsaxDragHandle!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxDragHandle: ImageVector? = null