Compare commits

..

9 Commits

44 changed files with 2102 additions and 328 deletions

View File

@ -97,8 +97,6 @@ jobs:
name: Android
needs: prepare-deps
runs-on: ubuntu-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps:
- uses: actions/checkout@v4
@ -135,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
@ -153,8 +151,6 @@ 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
@ -182,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
@ -212,8 +208,6 @@ 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
@ -233,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
@ -254,8 +248,6 @@ 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
@ -278,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

@ -1,96 +0,0 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

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

@ -53,6 +53,7 @@ import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
@ -80,6 +81,7 @@ fun CollectionDetails(
isFollowing: Boolean = false,
onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null,
) {
@ -151,6 +153,15 @@ fun CollectionDetails(
}
}
}
if (onEdit != null) {
ButtonGroupDivider()
GroupIconButton(onClick = onEdit) {
Icon(
imageVector = Iconsax.IconsaxEdit,
contentDescription = "Edit playlist",
)
}
}
}
}
val ownerInfo = @Composable {

View File

@ -103,6 +103,7 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -140,6 +141,7 @@ fun TrackList(
onBulkDownload: (List<MetadataTrack>) -> Unit = {},
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false,
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
@ -433,6 +435,11 @@ fun TrackList(
label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist,
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
onClick = { onBulkAddToPlaylist(targetTracks) },
),
),
trigger = { onClick ->
GroupIconButton(
@ -562,7 +569,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

@ -49,6 +49,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
@ -59,6 +60,7 @@ sealed interface TrackOptionsAction {
data object AddToQueue : TrackOptionsAction
data object RemoveFromQueue : TrackOptionsAction
data object ToggleFavorite : TrackOptionsAction
data object AddToPlaylist : TrackOptionsAction
data object Download : TrackOptionsAction
data object ToggleBlacklist : TrackOptionsAction
data object Share : TrackOptionsAction
@ -274,6 +276,14 @@ private fun buildTrackMenuItems(
),
)
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist,
label = "Add to playlist",
onClick = { onAction(TrackOptionsAction.AddToPlaylist) },
),
)
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive,

View File

@ -20,7 +20,12 @@ package dev.krtirtho.spotube.modules.album
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
@ -40,6 +45,8 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@ -50,6 +57,8 @@ fun AlbumScreen(albumId: String) {
val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<AlbumViewModel>(
key = albumId,
parameters = { parametersOf(albumId) }
@ -62,6 +71,13 @@ fun AlbumScreen(albumId: String) {
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -87,6 +103,10 @@ fun AlbumScreen(albumId: String) {
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
Scaffold(
@ -194,8 +214,26 @@ fun AlbumScreen(albumId: String) {
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
}
}

View File

@ -222,6 +222,7 @@ class AlbumViewModel(
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}

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,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -61,10 +60,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 +71,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,8 +88,15 @@ 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.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
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 kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@ -102,6 +108,8 @@ fun ArtistScreen(artistId: String) {
val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<ArtistViewModel>(
key = artistId,
parameters = { parametersOf(artistId) }
@ -114,6 +122,13 @@ fun ArtistScreen(artistId: String) {
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -139,6 +154,10 @@ fun ArtistScreen(artistId: String) {
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
Scaffold(
@ -214,6 +233,10 @@ fun ArtistScreen(artistId: String) {
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) },
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
@ -267,6 +290,20 @@ fun ArtistScreen(artistId: String) {
}
}
}
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
}
}
@ -349,7 +386,7 @@ private fun ArtistErrorContent(
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
)
Button(onClick = onRetry) {
PrimaryButton(onClick = onRetry) {
Text("Retry")
}
}
@ -452,7 +489,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 +553,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 +583,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

@ -269,6 +269,7 @@ class ArtistViewModel(
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}

View File

@ -22,6 +22,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
import dev.krtirtho.spotube.modules.plugin.PluginManager
import io.github.reactivecircus.cache4k.Cache
import kotlinx.coroutines.CoroutineScope
@ -276,4 +277,80 @@ class LibraryRepository(
savedArtistIds.value -= ids.toSet()
}
}
suspend fun currentUser(): MetadataUser? {
return plugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataUserAPI.getUser("")
}
}
}
}
suspend fun createPlaylist(
name: String,
description: String?,
isPublic: Boolean,
isCollaborating: Boolean,
imageBase64: String,
trackIds: List<String>,
): MetadataPlaylist? {
return plugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
val result = metadataPlaylistAPI.createPlaylist(
name = name,
description = description,
isPublic = isPublic,
isCollaborating = isCollaborating,
imageBase64 = imageBase64,
trackIds = trackIds,
)
playlistCache.invalidateAll()
savedPlaylistIds.value += result.id
result
}
}
}
}
suspend fun updatePlaylist(
id: String,
name: String?,
description: String?,
isPublic: Boolean?,
isCollaborating: Boolean?,
imageBase64: String?,
trackIds: List<String>?,
): MetadataPlaylist? {
return plugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
val result = metadataPlaylistAPI.updatePlaylist(
id = id,
name = name,
description = description,
isPublic = isPublic,
isCollaborating = isCollaborating,
imageBase64 = imageBase64,
trackIds = trackIds,
)
playlistCache.invalidateAll()
result
}
}
}
}
suspend fun addTracksToPlaylist(playlistId: String, trackIds: List<String>) {
plugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.addTracksToPlaylist(playlistId, trackIds)
}
}
playlistCache.invalidateAll()
}
}
}

View File

@ -0,0 +1,257 @@
/*
* Copyright (C) 2026 Kingmor 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.modules.library.playlist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.layout.ContentScale
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.playlist.MetadataPlaylist
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddToPlaylistPicker(
visible: Boolean,
currentUserId: String?,
onDismiss: () -> Unit,
onPlaylistSelected: (String) -> Unit,
modifier: Modifier = Modifier,
libraryRepository: LibraryRepository = koinInject(),
breakpointDp: Float = 600f,
) {
if (!visible) return
val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
var playlists by remember { mutableStateOf<List<MetadataPlaylist>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
LaunchedEffect(visible, currentUserId) {
if (visible) {
isLoading = true
scope.launch {
val result = libraryRepository.savedPlaylists()
playlists = result?.items?.filter {
currentUserId != null && it.owner?.id == currentUserId
} ?: emptyList()
isLoading = false
}
}
}
val filteredPlaylists = remember(playlists, searchQuery) {
if (searchQuery.isBlank()) {
playlists
} else {
playlists.filter {
it.title.contains(searchQuery, ignoreCase = true) ||
it.description?.contains(searchQuery, ignoreCase = true) == true
}
}
}
val content: @Composable () -> Unit = {
Column(modifier = Modifier.fillMaxSize()) {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
placeholder = { Text("Search your playlists...") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search",
)
},
)
PlaylistPickerContent(
playlists = filteredPlaylists,
isLoading = isLoading,
onPlaylistSelected = { playlistId ->
onPlaylistSelected(playlistId)
onDismiss()
},
)
}
}
if (isLargeScreen) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = {
Text("Add to Your Playlist")
},
text = {
Box(modifier = Modifier.fillMaxWidth().height(400.dp)) {
content()
}
},
confirmButton = {
OutlineButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
} else {
ModalBottomSheet(
onDismissRequest = onDismiss,
dragHandle = null,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Add to Your Playlist",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp),
)
content()
}
}
}
}
@Composable
private fun PlaylistPickerContent(
playlists: List<MetadataPlaylist>,
isLoading: Boolean,
onPlaylistSelected: (String) -> Unit,
) {
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Loading your playlists...")
}
} else if (playlists.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("You don't own any playlists yet.\nCreate one to add tracks.")
}
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(playlists, key = { it.id }) { playlist ->
PlaylistPickerRow(
playlist = playlist,
onClick = { onPlaylistSelected(playlist.id) },
)
}
}
}
}
@Composable
private fun PlaylistPickerRow(
playlist: MetadataPlaylist,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val imageUrl = playlist.thumbnails.firstOrNull()?.url
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = playlist.title,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = playlist.title.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = playlist.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${playlist.trackCount} tracks",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@ -22,10 +22,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@ -35,12 +37,15 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
@ -49,6 +54,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@ -62,6 +68,7 @@ fun LibraryPlaylistsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
var showCreatePlaylist by remember { mutableStateOf(false) }
LaunchedEffect(gridState) {
@ -84,8 +91,14 @@ fun LibraryPlaylistsScreen(
}
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedVisibility(
visible = searchMode,
modifier = Modifier.weight(1f),
) {
TextField(
value = (state as? LibraryPlaylistsState.Data)?.query ?: "",
@ -101,6 +114,16 @@ fun LibraryPlaylistsScreen(
}
)
}
IconButton(
onClick = { showCreatePlaylist = true },
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Create Playlist",
)
}
}
Spacer(modifier = Modifier.height(12.dp))
when (state) {
is LibraryPlaylistsState.Loading -> {
@ -187,5 +210,20 @@ fun LibraryPlaylistsScreen(
}
}
}
PlaylistFormSheet(
visible = showCreatePlaylist,
onDismiss = { showCreatePlaylist = false },
onSubmit = { data ->
viewModel.createPlaylist(
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64,
trackIds = emptyList(),
)
},
)
}
}

View File

@ -152,4 +152,31 @@ class LibraryPlaylistsViewModel(
loadInitialData()
}
}
fun createPlaylist(
name: String,
description: String?,
isPublic: Boolean,
isCollaborating: Boolean,
imageBase64: String,
trackIds: List<String>,
) {
viewModelScope.launch {
runCatching {
repository.createPlaylist(
name = name,
description = description,
isPublic = isPublic,
isCollaborating = isCollaborating,
imageBase64 = imageBase64,
trackIds = trackIds,
)
}.onFailure { e ->
logger.e(e) { "Failed to create playlist" }
}.onSuccess {
repository.invalidateCaches()
loadInitialData()
}
}
}
}

View File

@ -0,0 +1,335 @@
/*
* 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.modules.library.playlist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.TextField
import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
import io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
data class PlaylistFormData(
val name: String = "",
val description: String = "",
val isPublic: Boolean = true,
val isCollaborating: Boolean = false,
val imageBase64: String = "",
val imagePreviewUrl: String? = null,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistFormSheet(
visible: Boolean,
onDismiss: () -> Unit,
onSubmit: (PlaylistFormData) -> Unit,
modifier: Modifier = Modifier,
initialData: PlaylistFormData? = null,
isEditing: Boolean = false,
breakpointDp: Float = 600f,
) {
if (!visible) return
val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") }
var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") }
var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) }
var isCollaborating by remember(initialData) { mutableStateOf(initialData?.isCollaborating ?: false) }
var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") }
var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) }
val scope = rememberCoroutineScope()
val imagePicker = rememberFilePickerLauncher(
type = FileKitType.Image
) { file ->
if (file != null) {
scope.launch {
val bytes = file.readBytes()
val base64 = withContext(Dispatchers.Default) {
bytes.toByteString().base64()
}
imageBase64 = base64
imagePreviewUrl = null
}
}
}
val currentData = PlaylistFormData(
name = name,
description = description,
isPublic = isPublic,
isCollaborating = isCollaborating,
imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl,
)
val handleImagePick = { imagePicker.launch() }
if (isLargeScreen) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = {
Text(if (isEditing) "Edit Playlist" else "Create Playlist")
},
text = {
PlaylistFormFields(
name = name,
onNameChange = { name = it },
description = description,
onDescriptionChange = { description = it },
isPublic = isPublic,
onIsPublicChange = { isPublic = it },
isCollaborating = isCollaborating,
onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl,
onPickImage = handleImagePick,
)
},
confirmButton = {
PrimaryButton(
onClick = {
onSubmit(currentData)
onDismiss()
},
enabled = name.isNotBlank(),
) {
Text(if (isEditing) "Save" else "Create")
}
},
dismissButton = {
OutlineButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
} else {
ModalBottomSheet(
onDismissRequest = onDismiss,
dragHandle = null,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = if (isEditing) "Edit Playlist" else "Create Playlist",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp),
)
PlaylistFormFields(
name = name,
onNameChange = { name = it },
description = description,
onDescriptionChange = { description = it },
isPublic = isPublic,
onIsPublicChange = { isPublic = it },
isCollaborating = isCollaborating,
onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl,
onPickImage = handleImagePick,
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
OutlineButton(onClick = onDismiss) {
Text("Cancel")
}
PrimaryButton(
onClick = {
onSubmit(currentData)
onDismiss()
},
enabled = name.isNotBlank(),
) {
Text(if (isEditing) "Save" else "Create")
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun PlaylistFormFields(
name: String,
onNameChange: (String) -> Unit,
description: String,
onDescriptionChange: (String) -> Unit,
isPublic: Boolean,
onIsPublicChange: (Boolean) -> Unit,
isCollaborating: Boolean,
onIsCollaboratingChange: (Boolean) -> Unit,
imageBase64: String,
imagePreviewUrl: String?,
onPickImage: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TextField(
value = name,
onValueChange = onNameChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Playlist name") },
singleLine = true,
label = { Text("Name") },
)
TextField(
value = description,
onValueChange = onDescriptionChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Description (optional)") },
maxLines = 3,
label = { Text("Description") },
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { onIsPublicChange(!isPublic) }
.padding(horizontal = 4.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Public", style = MaterialTheme.typography.bodyLarge)
Text(
"Anyone can see this playlist",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = isPublic, onCheckedChange = onIsPublicChange)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { onIsCollaboratingChange(!isCollaborating) }
.padding(horizontal = 4.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Collaborative", style = MaterialTheme.typography.bodyLarge)
Text(
"Others can add tracks to this playlist",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Cover Image", style = MaterialTheme.typography.bodyLarge)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
when {
imagePreviewUrl != null -> AsyncImage(
model = imagePreviewUrl,
contentDescription = "Playlist cover",
modifier = Modifier.size(64.dp),
contentScale = ContentScale.Crop,
)
imageBase64.isNotBlank() -> {
Text(
"",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
else -> {
Text(
"No image",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
PrimaryButton(onClick = onPickImage) {
Text(if (imageBase64.isNotBlank() || imagePreviewUrl != null) "Change Image" else "Pick Image")
}
}
}
}
}

View File

@ -0,0 +1,325 @@
/*
* Copyright (C) 2026 Kingmor 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.modules.playlist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.layout.ContentScale
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.IconButton
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTracksToPlaylistDialog(
visible: Boolean,
playlistId: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
searchRepository: SearchRepository = koinInject(),
libraryRepository: LibraryRepository = koinInject(),
breakpointDp: Float = 600f,
) {
if (!visible) return
val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
var query by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var isSearching by remember { mutableStateOf(false) }
var hasSearched by remember { mutableStateOf(false) }
var addingTrackIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var addedTrackIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val scope = rememberCoroutineScope()
LaunchedEffect(visible) {
if (!visible) {
query = ""
searchResults = emptyList()
hasSearched = false
addingTrackIds = emptySet()
addedTrackIds = emptySet()
}
}
val handleSearch = {
if (query.isNotBlank() && !isSearching) {
isSearching = true
hasSearched = true
scope.launch {
val result = searchRepository.searchTracks(query)
searchResults = result.items.map { it.data }
isSearching = false
}
}
}
val handleAddTrack = { track: MetadataTrack ->
if (!addingTrackIds.contains(track.id) && !addedTrackIds.contains(track.id)) {
addingTrackIds = addingTrackIds + track.id
scope.launch {
libraryRepository.addTracksToPlaylist(playlistId, listOf(track.id))
addingTrackIds = addingTrackIds - track.id
addedTrackIds = addedTrackIds + track.id
}
}
}
val content: @Composable () -> Unit = {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Search tracks...") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search",
)
},
)
IconButton(onClick = handleSearch) {
Icon(
imageVector = Iconsax.IconsaxSearch,
contentDescription = "Search",
)
}
}
when {
isSearching -> {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
hasSearched && searchResults.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
Text("No tracks found")
}
}
searchResults.isNotEmpty() -> {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(searchResults, key = { it.id }) { track ->
TrackSearchResultRow(
track = track,
isAdding = addingTrackIds.contains(track.id),
isAdded = addedTrackIds.contains(track.id),
onAdd = { handleAddTrack(track) },
)
}
}
}
else -> {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
Text("Search for tracks to add")
}
}
}
}
}
if (isLargeScreen) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = {
Text("Add Tracks to Playlist")
},
text = {
Box(modifier = Modifier.fillMaxWidth().height(500.dp)) {
content()
}
},
confirmButton = {
PrimaryButton(onClick = onDismiss) {
Text("Done")
}
},
)
} else {
ModalBottomSheet(
onDismissRequest = onDismiss,
dragHandle = null,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(600.dp),
) {
Text(
text = "Add Tracks to Playlist",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp),
)
content()
}
}
}
}
@Composable
private fun TrackSearchResultRow(
track: MetadataTrack,
isAdding: Boolean,
isAdded: Boolean,
onAdd: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val imageUrl = track.album?.thumbnails?.firstOrNull()?.url
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = track.title,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(6.dp)),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = track.title.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = track.artists.joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
when {
isAdded -> {
Icon(
imageVector = Iconsax.IconsaxCheckCircle,
contentDescription = "Added",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
}
isAdding -> {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
else -> {
OutlineButton(onClick = onAdd) {
Text("Add")
}
}
}
}
}

View File

@ -17,11 +17,21 @@
package dev.krtirtho.spotube.modules.playlist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
@ -31,6 +41,7 @@ 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.OutlineButton
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
@ -40,6 +51,13 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
import dev.krtirtho.spotube.modules.playlist.AddTracksToPlaylistDialog
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@ -50,6 +68,8 @@ fun PlaylistScreen(playlistId: String) {
val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<PlaylistViewModel>(
key = playlistId,
parameters = { parametersOf(playlistId) }
@ -61,6 +81,11 @@ fun PlaylistScreen(playlistId: String) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
var showEditPlaylist by remember { mutableStateOf(false) }
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var showAddTracksDialog by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
@ -73,6 +98,10 @@ fun PlaylistScreen(playlistId: String) {
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
Scaffold(
@ -131,6 +160,8 @@ fun PlaylistScreen(playlistId: String) {
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -166,8 +197,27 @@ fun PlaylistScreen(playlistId: String) {
isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
)
},
footerContent = if (isOwner) {
{
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
OutlineButton(onClick = { showAddTracksDialog = true }) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = null,
)
Text("Add Tracks", modifier = Modifier.padding(start = 8.dp))
}
}
}
} else null,
tracks = dataState.tracks,
error = null,
hasMore = dataState.nextPagination != null,
@ -190,8 +240,61 @@ fun PlaylistScreen(playlistId: String) {
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
val dataState = state as? PlaylistScreenState.Data
val playlist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet(
visible = showEditPlaylist,
onDismiss = { showEditPlaylist = false },
isEditing = true,
initialData = playlist?.let {
PlaylistFormData(
name = it.title,
description = it.description.orEmpty(),
isPublic = true,
isCollaborating = false,
imagePreviewUrl = it.thumbnails.firstOrNull()?.url,
)
},
onSubmit = { data ->
viewModel.updatePlaylist(
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64.ifBlank { null },
)
},
)
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { selectedPlaylistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
selectedPlaylistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
AddTracksToPlaylistDialog(
visible = showAddTracksDialog,
playlistId = playlistId,
onDismiss = {
showAddTracksDialog = false
viewModel.refresh()
},
)
}
}

View File

@ -94,6 +94,9 @@ class PlaylistViewModel(
val savedPlaylistIds
get() = libraryRepository.savedPlaylistIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
init {
viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin
@ -102,10 +105,15 @@ class PlaylistViewModel(
.distinctUntilChanged()
.collect {
loadInitialData()
loadCurrentUser()
}
}
}
private suspend fun loadCurrentUser() {
_currentUserId.value = libraryRepository.currentUser()?.id
}
private suspend fun loadInitialData() = runCatching {
_state.value = PlaylistScreenState.Loading
val playlistInfo = repository.getPlaylistInfo(playlistId)
@ -179,6 +187,33 @@ class PlaylistViewModel(
}
}
fun updatePlaylist(
name: String?,
description: String?,
isPublic: Boolean?,
isCollaborating: Boolean?,
imageBase64: String?,
) {
viewModelScope.launch {
runCatching {
libraryRepository.updatePlaylist(
id = playlistId,
name = name,
description = description,
isPublic = isPublic,
isCollaborating = isCollaborating,
imageBase64 = imageBase64,
trackIds = null,
)
}.onFailure { e ->
logger.e(e) { "Failed to update playlist" }
}.onSuccess {
repository.invalidateCaches()
loadInitialData()
}
}
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModelScope.launch {
when (action) {
@ -224,6 +259,7 @@ class PlaylistViewModel(
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}

View File

@ -20,7 +20,12 @@ package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
@ -40,6 +45,9 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@ -53,6 +61,8 @@ fun SavedTracksScreen() {
val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() }
@ -63,6 +73,13 @@ fun SavedTracksScreen() {
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -88,6 +105,10 @@ fun SavedTracksScreen() {
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
Scaffold(
@ -187,8 +208,26 @@ fun SavedTracksScreen() {
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
}
}

View File

@ -197,6 +197,7 @@ class SavedTracksViewModel(
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}

View File

@ -57,8 +57,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -97,6 +99,8 @@ import dev.krtirtho.spotube.core.ui.component.UserCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
@ -115,10 +119,18 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val audioPlayerQueue: AudioPlayerQueue = koinInject()
val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle()
val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
val focusManager = LocalFocusManager.current
@ -175,6 +187,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
}
}
@ -295,6 +311,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = ::bulkAddToQueue,
onBulkPlayNext = ::bulkPlayNext,
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
modifier = Modifier
.fillMaxSize(),
)
@ -311,6 +331,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = ::bulkAddToQueue,
onBulkPlayNext = ::bulkPlayNext,
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
modifier = Modifier
.fillMaxSize(),
)
@ -357,6 +381,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}
}
}
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
}
}
@ -568,6 +606,7 @@ private fun SearchAllTab(
onBulkDownload: (List<MetadataTrack>) -> Unit,
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
modifier: Modifier = Modifier,
) {
if (query.isBlank()) {
@ -621,6 +660,7 @@ private fun SearchAllTab(
onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
headerContent = {
if (showTracks) {
@ -704,6 +744,7 @@ private fun SearchTracksTab(
onBulkDownload: (List<MetadataTrack>) -> Unit,
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
modifier: Modifier = Modifier,
) {
if (query.isBlank()) {
@ -729,6 +770,7 @@ private fun SearchTracksTab(
onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
simplified = true,
modifier = modifier,
)

View File

@ -91,7 +91,7 @@ fun AppBottombar(
contentDescription = label,
tint = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(20.dp)
)
if (screen == Routes.Library) {
DownloadBadgeIndicator(

View File

@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -33,10 +34,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -69,6 +68,9 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
// The floating player on small screens that appears above the floating AppBottombar
//
@ -185,15 +187,17 @@ fun AppFloatingPlayer(
audioPlayerQueue = audioPlayerQueue,
savedTracksViewModel = savedTracksViewModel,
)
OutlinedIconButton(
Spacer(modifier = Modifier.size(8.dp))
IconButton(
onClick = ::onPlayPause,
enabled = playerUiState.queue.isNotEmpty()
enabled = playerUiState.queue.isNotEmpty(),
theme = LocalBaseUITheme.current.iconButtons.outline.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape)
)
) {
Icon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}

View File

@ -22,7 +22,6 @@ import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -40,18 +39,22 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
@ -63,7 +66,10 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
import dev.krtirtho.spotube.tabs
import org.jetbrains.compose.resources.Font
import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.cookie_regular
@Composable
fun AppSidebar(
@ -93,19 +99,26 @@ fun AppSidebar(
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text(
text = "Spotube",
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily(
listOf(
Font(Res.font.cookie_regular, weight = FontWeight.Normal)
)
)
),
color = MaterialTheme.colorScheme.onSurface
)
}
GhostIconButton(
onClick = { expanded = !expanded }
) {
Icon(
imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken,
contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(22.dp)
.clickable { expanded = !expanded }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
@ -189,7 +202,7 @@ fun SidebarItem(
},
)
}
Spacer(modifier = Modifier.weight(if (expanded) 1f else 0f))
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
}
val buttonModifier = Modifier

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
@ -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,19 +211,17 @@ fun PlayerQueueContent(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Card {
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) },
@ -227,13 +233,12 @@ 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,30 +246,18 @@ 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,
) {
ListRowTile(
onClick = onPlayClick,
selected = item.isCurrent,
modifier = Modifier,
leading = {
Row(
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.background(rowColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
.height(72.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
FeatherIcons.MoreVertical,
Iconsax.IconsaxDragHandle,
contentDescription = if (reorderScope != null) "Reorder" else null,
modifier = Modifier
.size(24.dp)
@ -300,10 +293,9 @@ private fun QueueItemRow(
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
}
},
title = {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
@ -315,6 +307,8 @@ private fun QueueItemRow(
MaterialTheme.colorScheme.onSurface
},
)
},
subtitle = {
Text(
text = item.subtitle,
style = MaterialTheme.typography.bodySmall,
@ -322,10 +316,8 @@ private fun QueueItemRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
},
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

@ -1,20 +1,3 @@
/*
* 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
object Iconsax

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

View File

@ -0,0 +1,92 @@
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.IconsaxEdit: ImageVector
get() {
if (_IconsaxEdit != null) {
return _IconsaxEdit!!
}
_IconsaxEdit = ImageVector.Builder(
name = "IconsaxEdit",
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(15.48f, 3f)
horizontalLineTo(7.52f)
curveTo(4.07f, 3f, 2f, 5.06f, 2f, 8.52f)
verticalLineTo(16.47f)
curveTo(2f, 19.94f, 4.07f, 22f, 7.52f, 22f)
horizontalLineTo(15.47f)
curveTo(18.93f, 22f, 20.99f, 19.94f, 20.99f, 16.48f)
verticalLineTo(8.52f)
curveTo(21f, 5.06f, 18.93f, 3f, 15.48f, 3f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(21.02f, 2.98f)
curveTo(19.23f, 1.18f, 17.48f, 1.14f, 15.64f, 2.98f)
lineTo(14.51f, 4.1f)
curveTo(14.41f, 4.2f, 14.38f, 4.34f, 14.42f, 4.47f)
curveTo(15.12f, 6.92f, 17.08f, 8.88f, 19.53f, 9.58f)
curveTo(19.56f, 9.59f, 19.61f, 9.59f, 19.64f, 9.59f)
curveTo(19.74f, 9.59f, 19.84f, 9.55f, 19.91f, 9.48f)
lineTo(21.02f, 8.36f)
curveTo(21.93f, 7.45f, 22.38f, 6.58f, 22.38f, 5.69f)
curveTo(22.38f, 4.79f, 21.93f, 3.9f, 21.02f, 2.98f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(17.86f, 10.42f)
curveTo(17.59f, 10.29f, 17.33f, 10.16f, 17.09f, 10.01f)
curveTo(16.89f, 9.89f, 16.69f, 9.76f, 16.5f, 9.62f)
curveTo(16.34f, 9.52f, 16.16f, 9.37f, 15.98f, 9.22f)
curveTo(15.96f, 9.21f, 15.9f, 9.16f, 15.82f, 9.08f)
curveTo(15.51f, 8.83f, 15.18f, 8.49f, 14.87f, 8.12f)
curveTo(14.85f, 8.1f, 14.79f, 8.04f, 14.74f, 7.95f)
curveTo(14.64f, 7.84f, 14.49f, 7.65f, 14.36f, 7.44f)
curveTo(14.25f, 7.3f, 14.12f, 7.1f, 14f, 6.89f)
curveTo(13.85f, 6.64f, 13.72f, 6.39f, 13.6f, 6.13f)
curveTo(13.47f, 5.85f, 13.37f, 5.59f, 13.28f, 5.34f)
lineTo(7.9f, 10.72f)
curveTo(7.55f, 11.07f, 7.21f, 11.73f, 7.14f, 12.22f)
lineTo(6.71f, 15.2f)
curveTo(6.62f, 15.83f, 6.79f, 16.42f, 7.18f, 16.81f)
curveTo(7.51f, 17.14f, 7.96f, 17.31f, 8.46f, 17.31f)
curveTo(8.57f, 17.31f, 8.68f, 17.3f, 8.79f, 17.29f)
lineTo(11.76f, 16.87f)
curveTo(12.25f, 16.8f, 12.91f, 16.47f, 13.26f, 16.11f)
lineTo(18.64f, 10.73f)
curveTo(18.39f, 10.65f, 18.14f, 10.54f, 17.86f, 10.42f)
close()
}
}
}.build()
return _IconsaxEdit!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxEdit: ImageVector? = null

View File

@ -104,6 +104,8 @@ object FakeMetadataStore {
fun getUser(id: String): MetadataUser? = users[id]?.toModel()
fun getCurrentUser(): MetadataUser? = users[CURRENT_USER_ID]?.toModel()
fun getTrack(id: String): MetadataTrack = tracks[id]?.toModel()
?: error("Track not found: $id")

View File

@ -23,4 +23,8 @@ class RealMetadataUserAPI : MetadataUserAPI {
override suspend fun getUser(id: String): MetadataUser? {
return FakeMetadataStore.getUser(id)
}
override suspend fun me(): MetadataUser? {
return FakeMetadataStore.getCurrentUser()
}
}