Compare commits

...

16 Commits

Author SHA1 Message Date
dependabot[bot]
9ad2c5c5c3
Merge 4dd0fee692 into 16d4adf59c 2026-07-17 04:10:08 +00:00
Kingkor Roy Tirtho
16d4adf59c cd: rename build artifacts for Android, Linux, Windows, and macOS for improved clarity 2026-07-17 10:08:54 +06:00
Kingkor Roy Tirtho
3d7d15541f feat: refactor PluginPermissionsDialog to use custom UI components for improved consistency and readability 2026-07-16 23:41:58 +06:00
Kingkor Roy Tirtho
70cabedc9e feat: replace FeatherIcons with Iconsax in PluginCard and PluginPermissionsDialog for improved UI consistency 2026-07-16 22:29:29 +06:00
Kingkor Roy Tirtho
0b3d21f255 feat: update icons in settings sections to use Iconsax library for improved UI consistency 2026-07-16 22:22:27 +06:00
Kingkor Roy Tirtho
9d79d055ba feat: implement settings section cards for updates, desktop, language, caching, and plugins 2026-07-16 01:28:48 +06:00
Kingkor Roy Tirtho
a590fa59f9 refactor: simplify SettingsScreen and PluginScreen by removing unused pluginManager references and enhancing DefaultAbilityPluginSelector 2026-07-15 23:26:15 +06:00
Kingkor Roy Tirtho
1ef1eff3cb feat: add Toggle component and update UI to use it in place of Switch 2026-07-15 23:11:12 +06:00
Kingkor Roy Tirtho
6ac934ad19 refactor: update UI components in AppBottombar, AppFloatingPlayer, AppSidebar, and LibraryPlaylistsScreen for consistency and improved theming 2026-07-10 20:13:37 +06:00
Kingkor Roy Tirtho
49e969cc0f feat: add 'Add to Playlist' functionality with UI components and dialogs 2026-07-10 19:06:05 +06:00
Kingkor Roy Tirtho
8692c344d6 feat: implement playlist creation and editing functionality with UI components 2026-07-10 17:24:32 +06:00
Kingkor Roy Tirtho
a38b9e48cc fix: convert buildCurrentMediaItemList to a suspend function for coroutine support 2026-07-10 13:42:37 +06:00
Kingkor Roy Tirtho
2aae112cbe feat: add genre and featured browsing capabilities to MediaBrowseHelper 2026-07-10 13:31:04 +06:00
Kingkor Roy Tirtho
d53ac853a5 refactor: replace button components with themed variants and introduce ListRowTile 2026-07-10 13:06:25 +06:00
Kingkor Roy Tirtho
69a464dcd2 cd: fix version name conflict 2026-07-10 11:52:20 +06:00
Kingkor Roy Tirtho
941b333a79 refactor: update UI component states to use normal theme colors 2026-07-10 11:49:03 +06:00
89 changed files with 6429 additions and 1915 deletions

View File

@ -97,8 +97,6 @@ jobs:
name: Android name: Android
needs: prepare-deps needs: prepare-deps
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -135,26 +133,23 @@ jobs:
EOF EOF
- name: Assemble Release - name: Assemble Release
run: ./gradlew :composeApp:assembleRelease ${{ env.GRADLE_FLAGS }} run: ./gradlew :composeApp:assembleRelease
- name: Rename APK
run: |
APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1)
cp "$APK_FILE" Spotube-android-all-arch.apk
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: android-apk name: android-apk
path: composeApp/build/outputs/apk/release/*.apk path: Spotube-android-all-arch.apk
- name: Upload AAB
uses: actions/upload-artifact@v4
with:
name: android-aab
path: composeApp/build/outputs/bundle/release/*.aab
build-linux: build-linux:
name: Linux Desktop name: Linux Desktop
needs: prepare-deps needs: prepare-deps
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -182,38 +177,45 @@ jobs:
run: chmod +x gradlew run: chmod +x gradlew
- name: Package DEB - name: Package DEB
run: ./gradlew :composeApp:packageReleaseDeb ${{ env.GRADLE_FLAGS }} run: ./gradlew :composeApp:packageReleaseDeb
- name: Package RPM - name: Package RPM
run: ./gradlew :composeApp:packageReleaseRpm ${{ env.GRADLE_FLAGS }} run: ./gradlew :composeApp:packageReleaseRpm
- name: Package AppImage - name: Package AppImage
run: ./gradlew :composeApp:packageReleaseAppImage ${{ env.GRADLE_FLAGS }} run: ./gradlew :composeApp:packageReleaseAppImage
- name: Rename Linux artifacts
run: |
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1)
APPIMAGE_FILE=$(find composeApp/build -name "*.AppImage" -type f | head -1)
cp "$DEB_FILE" Spotube-linux-x86_64.deb
cp "$RPM_FILE" Spotube-linux-x86_64.rpm
cp "$APPIMAGE_FILE" Spotube-linux-x86_64.AppImage
- name: Upload DEB - name: Upload DEB
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: linux-deb name: linux-deb
path: composeApp/build/compose/binaries/main-release/**/*.deb path: Spotube-linux-x86_64.deb
- name: Upload RPM - name: Upload RPM
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: linux-rpm name: linux-rpm
path: composeApp/build/compose/binaries/main-release/**/*.rpm path: Spotube-linux-x86_64.rpm
- name: Upload AppImage - name: Upload AppImage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: linux-appimage name: linux-appimage
path: composeApp/build/compose/binaries/main-release/**/*.AppImage path: Spotube-linux-x86_64.AppImage
build-windows: build-windows:
name: Windows Desktop name: Windows Desktop
needs: prepare-deps needs: prepare-deps
runs-on: windows-latest runs-on: windows-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -232,30 +234,25 @@ jobs:
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
- name: Package MSI
run: ./gradlew :composeApp:packageReleaseMsi ${{ env.GRADLE_FLAGS }}
- name: Package EXE - name: Package EXE
run: ./gradlew :composeApp:packageReleaseExe ${{ env.GRADLE_FLAGS }} run: ./gradlew :composeApp:packageReleaseExe
- name: Upload MSI - name: Rename Windows artifacts
uses: actions/upload-artifact@v4 shell: pwsh
with: run: |
name: windows-msi $EXE_FILE = Get-ChildItem -Path composeApp/build -Filter "*.exe" -Recurse | Select-Object -First 1
path: composeApp/build/compose/binaries/main-release/**/*.msi Copy-Item $EXE_FILE.FullName Spotube-windows-x86_64-setup.exe
- name: Upload EXE - name: Upload EXE
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: windows-exe name: windows-exe
path: composeApp/build/compose/binaries/main-release/**/*.exe path: Spotube-windows-x86_64-setup.exe
build-macos: build-macos:
name: macOS Desktop name: macOS Desktop
needs: prepare-deps needs: prepare-deps
runs-on: macos-latest runs-on: macos-latest
env:
GRADLE_FLAGS: ${{ inputs.release_type == 'nightly' && '-PversionName=nightly' || '' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -278,13 +275,18 @@ jobs:
run: chmod +x gradlew run: chmod +x gradlew
- name: Package DMG - name: Package DMG
run: ./gradlew :composeApp:packageReleaseDmg ${{ env.GRADLE_FLAGS }} run: ./gradlew :composeApp:packageReleaseDmg
- name: Rename macOS artifact
run: |
DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1)
cp "$DMG_FILE" Spotube-macos-universal.dmg
- name: Upload DMG - name: Upload DMG
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: macos-dmg name: macos-dmg
path: composeApp/build/compose/binaries/main-release/**/*.dmg path: Spotube-macos-universal.dmg
create-release: create-release:
name: Create GitHub Release name: Create GitHub Release

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 { companion object {
const val MEDIA_ID_ROOT = "root" const val MEDIA_ID_ROOT = "root"
const val MEDIA_ID_BROWSE = "browse" 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_LIBRARY = "library"
const val MEDIA_ID_SAVED_PLAYLISTS = "library:playlists" const val MEDIA_ID_SAVED_PLAYLISTS = "library:playlists"
const val MEDIA_ID_SAVED_ALBUMS = "library:albums" const val MEDIA_ID_SAVED_ALBUMS = "library:albums"
@ -93,6 +95,11 @@ class MediaBrowseHelper(
return when { return when {
parentId == MEDIA_ID_ROOT -> buildRootChildren() parentId == MEDIA_ID_ROOT -> buildRootChildren()
parentId == MEDIA_ID_BROWSE -> buildBrowseItems() 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_LIBRARY -> buildLibraryRootChildren()
parentId == MEDIA_ID_SAVED_PLAYLISTS -> buildSavedPlaylistsItems() parentId == MEDIA_ID_SAVED_PLAYLISTS -> buildSavedPlaylistsItems()
parentId == MEDIA_ID_SAVED_ALBUMS -> buildSavedAlbumsItems() parentId == MEDIA_ID_SAVED_ALBUMS -> buildSavedAlbumsItems()
@ -214,7 +221,6 @@ class MediaBrowseHelper(
} }
private fun buildRootChildren(): List<MediaItem> { private fun buildRootChildren(): List<MediaItem> {
val gridExtras = Bundle().apply { putInt(CONTENT_STYLE_BROWSABLE, CONTENT_STYLE_GRID) }
return listOf( return listOf(
MediaItem.Builder() MediaItem.Builder()
.setMediaId(MEDIA_ID_BROWSE) .setMediaId(MEDIA_ID_BROWSE)
@ -223,7 +229,6 @@ class MediaBrowseHelper(
.setTitle("Browse") .setTitle("Browse")
.setIsBrowsable(true) .setIsBrowsable(true)
.setIsPlayable(false) .setIsPlayable(false)
.setExtras(gridExtras)
.build() .build()
) )
.build(), .build(),
@ -251,27 +256,67 @@ class MediaBrowseHelper(
} }
private suspend fun buildBrowseItems(): List<MediaItem> { 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 { val featured = try {
homeScreenRepository.featuredItems() ?: emptyList() homeScreenRepository.featuredItems() ?: emptyList()
} catch (_: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
return featured.mapNotNull { item -> browseItemToMediaItem(item) }
}
private suspend fun buildBrowseGenreItems(genreId: String): List<MediaItem> {
val sections = try { val sections = try {
homeScreenRepository.list()?.items ?: emptyList() homeScreenRepository.list(genreId)?.items ?: emptyList()
} catch (_: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
val items = mutableListOf<MediaItem>() 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 (section in sections) {
for (item in section.items) { for (item in section.items) {
val mediaItem = browseItemToMediaItem( val mediaItem = browseItemToMediaItem(

View File

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

View File

@ -85,7 +85,7 @@ val navigationModule = module {
LibraryScreen() LibraryScreen()
} }
navigation<Routes.Settings> { navigation<Routes.Settings> {
SettingsScreen(pluginManager = get(), settingsViewModel = koinViewModel()) SettingsScreen(settingsViewModel = koinViewModel())
} }
navigation<Routes.Plugins> { navigation<Routes.Plugins> {
PluginScreen(pluginManager = get()) PluginScreen(pluginManager = get())

View File

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

View File

@ -106,11 +106,11 @@ private fun resolveButtonState(
) )
else -> ResolvedButtonState( else -> ResolvedButtonState(
style.colors.focused, style.colors.normal,
style.shape.focused, style.shape.normal,
style.shadow.focused, style.shadow.normal,
style.border.focused, style.border.normal,
style.padding.focused style.padding.normal
) )
} }
} }
@ -299,6 +299,26 @@ fun IconButton(
} }
} }
@Composable
fun GhostButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable RowScope.() -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.ghost
OutlineButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
hoverOnly = true,
theme = style,
content = content,
)
}
@Composable @Composable
fun GhostIconButton( fun GhostIconButton(
onClick: () -> Unit, onClick: () -> Unit,
@ -308,36 +328,13 @@ fun GhostIconButton(
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val baseTheme = LocalBaseUITheme.current val baseTheme = LocalBaseUITheme.current
val outlineStyle = baseTheme.iconButtons.outline val style = theme ?: baseTheme.iconButtons.ghost
val ghostStyle = theme ?: BaseUITheme.ButtonStyle(
colors = BaseUITheme.InteractionState(
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,
),
),
shape = outlineStyle.shape,
shadow = BaseUITheme.InteractionState(
hovered = outlineStyle.shadow.hovered,
pressed = outlineStyle.shadow.pressed,
focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
),
border = BaseUITheme.InteractionState(
hovered = outlineStyle.border.hovered,
pressed = outlineStyle.border.pressed,
focused = BaseUITheme.Border(Color.Transparent, 0.dp),
),
padding = outlineStyle.padding,
)
OutlineButton( OutlineButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
hoverOnly = true, hoverOnly = true,
theme = ghostStyle, theme = style,
) { ) {
content() content()
} }

View File

@ -85,7 +85,7 @@ private fun resolveCheckBoxState(
return when { return when {
isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed) 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) 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 { return when {
isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed) 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) 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,186 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
private val DefaultDialogMaxWidth: Dp = 440.dp
@Composable
fun ThemedDialog(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
title: @Composable (() -> Unit)? = null,
actions: @Composable (() -> Unit)? = null,
theme: BaseUITheme.DialogTheme? = null,
properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false),
content: @Composable () -> Unit,
) {
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
Dialog(
onDismissRequest = onDismissRequest,
properties = properties,
) {
AnimatedVisibility(
visible = true,
enter = scaleIn(
initialScale = 0.92f,
animationSpec = tween(280),
) + fadeIn(animationSpec = tween(200)),
exit = scaleOut(
targetScale = 0.92f,
animationSpec = tween(180),
) + fadeOut(animationSpec = tween(150)),
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.widthIn(max = DefaultDialogMaxWidth)
.shadow(
elevation = dialogTheme.shadow.elevation,
shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) },
ambientColor = dialogTheme.shadow.ambientColor,
spotColor = dialogTheme.shadow.spotColor,
)
.clip(dialogTheme.shape)
.background(dialogTheme.background, dialogTheme.shape)
.border(
BorderStroke(dialogTheme.border.width, dialogTheme.border.color),
dialogTheme.shape
)
.highlight(Color.White.copy(alpha = 0.08f)),
) {
Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) {
if (title != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, top = 24.dp),
) {
title()
}
Spacer(Modifier.height(8.dp))
}
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
.padding(
start = 24.dp,
end = 24.dp,
top = if (title == null) 24.dp else 0.dp,
),
) {
content()
}
if (actions != null) {
Spacer(Modifier.height(16.dp))
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
actions()
}
}
} else {
Spacer(Modifier.height(24.dp))
}
}
}
}
}
}
}
@Preview
@Composable
private fun ThemedDialogPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
var show by remember { mutableStateOf(true) }
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
) {
PrimaryButton(onClick = { show = true }) {
Text("Open Dialog")
}
if (show) {
ThemedDialog(onDismissRequest = { show = false }) {
Text(
"This is a themed dialog with glass-like gradient, shadow, and scale animation.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
}

View File

@ -0,0 +1,172 @@
/*
* 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.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
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.layout.widthIn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@Composable
fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 4.dp),
theme: BaseUITheme.DropdownMenuTheme? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu
androidx.compose.material3.DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
offset = offset,
modifier = modifier
.widthIn(min = 180.dp)
.shadow(
elevation = menuTheme.shadow.elevation,
shape = menuTheme.shape,
ambientColor = menuTheme.shadow.ambientColor,
spotColor = menuTheme.shadow.spotColor,
)
.clip(menuTheme.shape)
.background(menuTheme.background, menuTheme.shape)
.border(
BorderStroke(menuTheme.border.width, menuTheme.border.color),
menuTheme.shape,
),
) {
content()
}
}
@Composable
fun DropdownMenuItem(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
selected: Boolean = false,
theme: BaseUITheme.DropdownMenuTheme? = null,
) {
val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
Row(
modifier = modifier
.fillMaxWidth()
.clip(menuTheme.shape)
.hoverable(interactionSource)
.then(
if (isHovered && enabled) {
Modifier.background(menuTheme.itemHoverBackground)
} else {
Modifier
}
)
.then(
if (isHovered && enabled) {
Modifier.highlight(menuTheme.itemHighlight)
} else {
Modifier
}
)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
.padding(menuTheme.itemPadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (leadingIcon != null) {
Icon(
imageVector = leadingIcon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (enabled) {
menuTheme.itemForeground
} else {
menuTheme.itemForeground.copy(alpha = 0.38f)
},
)
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) {
menuTheme.itemForeground
} else {
menuTheme.itemForeground.copy(alpha = 0.38f)
},
modifier = Modifier.weight(1f),
maxLines = 1,
softWrap = false,
)
}
}
@Composable
fun DropdownMenuDivider(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
)
}
}

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

@ -0,0 +1,177 @@
/*
* 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.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
private val RadioSize = 22.dp
private val RadioDotSize = 10.dp
private data class ResolvedRadioState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
)
@Composable
private fun resolveRadioState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedRadioState {
return when {
isPressed -> ResolvedRadioState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
isHovered -> ResolvedRadioState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
else -> ResolvedRadioState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal)
}
}
@Composable
fun Radio(
selected: Boolean,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.CheckBoxTheme? = null,
) {
val baseTheme = LocalBaseUITheme.current.checkBox
val radioTheme = theme ?: baseTheme
val source = remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState()
val isHovered by source.collectIsHoveredAsState()
val style = if (selected) radioTheme.selected else radioTheme.unselected
val resolved = resolveRadioState(style, isPressed, isHovered)
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
val dotProgress by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(durationMillis = 180),
label = "radioDot",
)
Box(
modifier = modifier
.size(RadioSize)
.graphicsLayer {
translationY = lift.toPx()
shape = CircleShape
clip = true
}
.then(
if (resolved.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = resolved.shadow.elevation,
shape = CircleShape,
ambientColor = resolved.shadow.ambientColor,
spotColor = resolved.shadow.spotColor,
)
} else Modifier
)
.clip(CircleShape)
.background(resolved.colors.background, CircleShape)
.border(BorderStroke(0.5.dp, resolved.border.color), CircleShape)
.highlight(resolved.colors.highlight)
.then(
if (onClick != null) {
Modifier
.hoverable(interactionSource = source, enabled = enabled)
.clickable(
interactionSource = source,
indication = ripple(bounded = true, radius = RadioSize / 2),
enabled = enabled,
onClick = onClick,
)
} else {
Modifier
}
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(RadioDotSize * dotProgress)
.clip(CircleShape)
.background(radioTheme.checkmarkColor, CircleShape),
)
}
}
@Preview
@Composable
private fun RadioPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Radio(selected = true, onClick = {})
Text("Selected", style = MaterialTheme.typography.bodyMedium)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Radio(selected = false, onClick = {})
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}

View File

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

View File

@ -81,7 +81,7 @@ private fun resolveTextFieldState(
return when { return when {
isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused) 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) 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

@ -48,25 +48,30 @@ data class BaseUITheme(
val slider: SliderTheme, val slider: SliderTheme,
val checkBox: CheckBoxTheme, val checkBox: CheckBoxTheme,
val chipTab: ChipTabTheme, val chipTab: ChipTabTheme,
val toggle: ToggleTheme,
val card: CardTheme, val card: CardTheme,
val listRowTile: ListRowTheme, val listRowTile: ListRowTheme,
val dialog: DialogTheme,
val dropdownMenu: DropdownMenuTheme,
) { ) {
@Stable @Stable
data class InteractionState<T>( data class InteractionState<T>(
val normal: T,
val hovered: T, val hovered: T,
val pressed: T, val pressed: T,
val focused: T, val focused: T,
) { ) {
companion object { companion object {
fun <T> fromSingleValue(value: T): InteractionState<T> { fun <T> fromSingleValue(value: T): InteractionState<T> {
return InteractionState(value, value, value) return InteractionState(value, value, value, value)
} }
} }
} }
@Stable @Stable
data class AdvancedInteractionState<T>( data class AdvancedInteractionState<T>(
val normal: T,
val hovered: T, val hovered: T,
val pressed: T, val pressed: T,
val focused: T, val focused: T,
@ -75,7 +80,7 @@ data class BaseUITheme(
) { ) {
companion object { companion object {
fun <T> fromSingleValue(value: T): AdvancedInteractionState<T> { fun <T> fromSingleValue(value: T): AdvancedInteractionState<T> {
return AdvancedInteractionState(value, value, value, value, value) return AdvancedInteractionState(value, value, value, value, value, value)
} }
} }
} }
@ -156,6 +161,27 @@ data class BaseUITheme(
val shadowElevation: Dp, val shadowElevation: Dp,
) )
data class DialogTheme(
val background: Brush,
val scrim: Color,
val shape: Shape,
val border: Border,
val shadow: Shadow,
val padding: PaddingValues,
)
data class DropdownMenuTheme(
val background: Brush,
val shape: Shape,
val border: Border,
val shadow: Shadow,
val itemHoverBackground: Brush,
val itemForeground: Color,
val itemHighlight: Color,
val padding: PaddingValues,
val itemPadding: PaddingValues,
)
data class SliderTheme( data class SliderTheme(
val trackActiveColor: InteractionState<Color>, val trackActiveColor: InteractionState<Color>,
val trackInactiveColor: InteractionState<Color>, val trackInactiveColor: InteractionState<Color>,
@ -175,6 +201,13 @@ data class BaseUITheme(
val selected: ButtonStyle, val selected: ButtonStyle,
val unselected: ButtonStyle, val unselected: ButtonStyle,
) )
data class ToggleTheme(
val checked: ButtonStyle,
val unchecked: ButtonStyle,
val thumb: InteractionState<Color>,
val thumbShadow: InteractionState<Shadow>,
)
} }
val LocalBaseUITheme = staticCompositionLocalOf<BaseUITheme> { val LocalBaseUITheme = staticCompositionLocalOf<BaseUITheme> {
@ -200,23 +233,30 @@ fun rememberBaseUITheme(): BaseUITheme {
val defaultShape = RoundedCornerShape(14.dp) val defaultShape = RoundedCornerShape(14.dp)
val defaultShapeState = val defaultShapeState =
BaseUITheme.InteractionState<Shape>(defaultShape, defaultShape, defaultShape) BaseUITheme.InteractionState<Shape>(defaultShape, defaultShape, defaultShape, defaultShape)
val noBorder = BaseUITheme.Border(Color.Transparent, 0.dp) 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 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( 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),
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( val iconButtonPadding = BaseUITheme.InteractionState(
PaddingValues(8.dp), PaddingValues(8.dp),
PaddingValues(8.dp), PaddingValues(8.dp),
PaddingValues(8.dp), PaddingValues(8.dp),
PaddingValues(8.dp),
) )
val outlineColors = BaseUITheme.InteractionState( val outlineColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
foreground = scheme.onSurface,
highlight = highlight,
),
hovered = BaseUITheme.ButtonColors( hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
foreground = scheme.onSurface, foreground = scheme.onSurface,
@ -234,6 +274,12 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
) )
val outlineShadow = BaseUITheme.InteractionState( val outlineShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
6.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
hovered = BaseUITheme.Shadow( hovered = BaseUITheme.Shadow(
9.dp, 9.dp,
true, true,
@ -254,12 +300,18 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
) )
val outlineBorder = BaseUITheme.InteractionState( val outlineBorder = BaseUITheme.InteractionState(
normal = BaseUITheme.Border(border, 0.5.dp),
hovered = BaseUITheme.Border(border, 0.5.dp), hovered = BaseUITheme.Border(border, 0.5.dp),
pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp), pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp),
focused = BaseUITheme.Border(border, 0.5.dp), focused = BaseUITheme.Border(border, 0.5.dp),
) )
val primaryColors = BaseUITheme.InteractionState( 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( hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
foreground = scheme.onPrimary, foreground = scheme.onPrimary,
@ -282,6 +334,12 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
) )
val primaryShadow = BaseUITheme.InteractionState( 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( hovered = BaseUITheme.Shadow(
9.dp, 9.dp,
true, true,
@ -302,12 +360,23 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
) )
val primaryBorder = BaseUITheme.InteractionState( val primaryBorder = BaseUITheme.InteractionState(
normal = BaseUITheme.Border(scheme.primary, 0.5.dp),
hovered = BaseUITheme.Border(scheme.primary, 0.5.dp), hovered = BaseUITheme.Border(scheme.primary, 0.5.dp),
pressed = BaseUITheme.Border(scheme.primary, 0.5.dp), pressed = BaseUITheme.Border(scheme.primary, 0.5.dp),
focused = BaseUITheme.Border(scheme.primary, 0.5.dp), focused = BaseUITheme.Border(scheme.primary, 0.5.dp),
) )
val secondaryColors = BaseUITheme.InteractionState( 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( hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient( background = Brush.verticalGradient(
listOf( listOf(
@ -340,6 +409,12 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
) )
val secondaryShadow = BaseUITheme.InteractionState( val secondaryShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
6.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
hovered = BaseUITheme.Shadow( hovered = BaseUITheme.Shadow(
9.dp, 9.dp,
true, true,
@ -360,12 +435,18 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
) )
val secondaryBorder = BaseUITheme.InteractionState( 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), hovered = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
pressed = 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), focused = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
) )
val ghostColors = BaseUITheme.InteractionState( val ghostColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
foreground = scheme.onSurface,
highlight = Color.Transparent,
),
hovered = BaseUITheme.ButtonColors( hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient( background = Brush.verticalGradient(
listOf( listOf(
@ -447,6 +528,7 @@ fun rememberBaseUITheme(): BaseUITheme {
) )
val textFieldForeground = BaseUITheme.InteractionState( val textFieldForeground = BaseUITheme.InteractionState(
normal = scheme.onSurface,
hovered = scheme.onSurface, hovered = scheme.onSurface,
pressed = scheme.onSurface, pressed = scheme.onSurface,
focused = scheme.onSurface, focused = scheme.onSurface,
@ -454,17 +536,28 @@ fun rememberBaseUITheme(): BaseUITheme {
val checkBoxShape = RoundedCornerShape(6.dp) val checkBoxShape = RoundedCornerShape(6.dp)
val checkBoxShapeState = val checkBoxShapeState =
BaseUITheme.InteractionState<Shape>(checkBoxShape, checkBoxShape, checkBoxShape) BaseUITheme.InteractionState<Shape>(checkBoxShape, checkBoxShape, checkBoxShape, checkBoxShape)
val noPadding = 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 chipTabShape = RoundedCornerShape(10.dp)
val chipTabShapeState = val chipTabShapeState =
BaseUITheme.InteractionState<Shape>(chipTabShape, chipTabShape, chipTabShape) BaseUITheme.InteractionState<Shape>(chipTabShape, chipTabShape, chipTabShape, chipTabShape)
val chipTabPadding = BaseUITheme.InteractionState( 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),
PaddingValues(horizontal = 14.dp, vertical = 4.dp), PaddingValues(horizontal = 14.dp, vertical = 4.dp),
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
)
val toggleShape = RoundedCornerShape(50)
val toggleShapeState =
BaseUITheme.InteractionState<Shape>(toggleShape, toggleShape, toggleShape, toggleShape)
val togglePadding = BaseUITheme.InteractionState(
PaddingValues(2.dp),
PaddingValues(2.dp),
PaddingValues(2.dp),
PaddingValues(2.dp),
) )
return BaseUITheme( return BaseUITheme(
@ -482,26 +575,36 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
textField = TextFieldTheme( textField = TextFieldTheme(
background = BaseUITheme.InteractionState( background = BaseUITheme.InteractionState(
normal = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)), hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
pressed = Brush.verticalGradient(listOf(containerLighter, containerDarker)), pressed = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)), focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
), ),
highlight = BaseUITheme.InteractionState( highlight = BaseUITheme.InteractionState(
normal = highlight,
hovered = highlight, hovered = highlight,
pressed = highlight, pressed = highlight,
focused = highlight, focused = highlight,
), ),
shape = BaseUITheme.InteractionState( shape = BaseUITheme.InteractionState(
normal = defaultShape,
hovered = defaultShape, hovered = defaultShape,
pressed = defaultShape, pressed = defaultShape,
focused = defaultShape, focused = defaultShape,
), ),
border = BaseUITheme.InteractionState( 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), hovered = BaseUITheme.Border(border.copy(alpha = 0.85f), 0.5.dp),
pressed = BaseUITheme.Border(border, 0.5.dp), pressed = BaseUITheme.Border(border, 0.5.dp),
focused = BaseUITheme.Border(scheme.primary, 0.5.dp), focused = BaseUITheme.Border(scheme.primary, 0.5.dp),
), ),
shadow = BaseUITheme.InteractionState( shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
9.dp,
true,
shadowColor.copy(alpha = 0.22f),
shadowColor.copy(alpha = 0.26f)
),
hovered = BaseUITheme.Shadow( hovered = BaseUITheme.Shadow(
9.dp, 9.dp,
true, true,
@ -540,21 +643,30 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
slider = SliderTheme( slider = SliderTheme(
trackActiveColor = BaseUITheme.InteractionState( trackActiveColor = BaseUITheme.InteractionState(
normal = scheme.primary,
hovered = scheme.primary, hovered = scheme.primary,
pressed = scheme.primary, pressed = scheme.primary,
focused = scheme.primary, focused = scheme.primary,
), ),
trackInactiveColor = BaseUITheme.InteractionState( trackInactiveColor = BaseUITheme.InteractionState(
normal = border.copy(alpha = 0.6f),
hovered = border.copy(alpha = 0.6f), hovered = border.copy(alpha = 0.6f),
pressed = border.copy(alpha = 0.6f), pressed = border.copy(alpha = 0.6f),
focused = border.copy(alpha = 0.6f), focused = border.copy(alpha = 0.6f),
), ),
thumbColor = BaseUITheme.InteractionState( thumbColor = BaseUITheme.InteractionState(
normal = scheme.primary,
hovered = scheme.primary, hovered = scheme.primary,
pressed = scheme.primary, pressed = scheme.primary,
focused = scheme.primary, focused = scheme.primary,
), ),
thumbShadow = BaseUITheme.InteractionState( thumbShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
5.dp,
true,
scheme.primary.copy(alpha = 0.35f),
scheme.primary.copy(alpha = 0.4f)
),
hovered = BaseUITheme.Shadow( hovered = BaseUITheme.Shadow(
8.dp, 8.dp,
true, true,
@ -582,6 +694,12 @@ fun rememberBaseUITheme(): BaseUITheme {
colors = primaryColors, colors = primaryColors,
shape = checkBoxShapeState, shape = checkBoxShapeState,
shadow = BaseUITheme.InteractionState( shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
6.dp,
true,
scheme.primary.copy(alpha = 0.3f),
scheme.primary.copy(alpha = 0.35f)
),
hovered = BaseUITheme.Shadow( hovered = BaseUITheme.Shadow(
8.dp, 8.dp,
true, true,
@ -608,6 +726,12 @@ fun rememberBaseUITheme(): BaseUITheme {
colors = outlineColors, colors = outlineColors,
shape = checkBoxShapeState, shape = checkBoxShapeState,
shadow = BaseUITheme.InteractionState( shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
3.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
hovered = BaseUITheme.Shadow( hovered = BaseUITheme.Shadow(
5.dp, 5.dp,
true, true,
@ -648,6 +772,161 @@ fun rememberBaseUITheme(): BaseUITheme {
padding = chipTabPadding, padding = chipTabPadding,
), ),
), ),
toggle = BaseUITheme.ToggleTheme(
checked = BaseUITheme.ButtonStyle(
colors = 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,
highlight = Color.White.copy(alpha = 0.25f),
),
pressed = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
scheme.primary.copy(alpha = 0.85f),
scheme.primary
)
),
foreground = scheme.onPrimary,
highlight = Color.White.copy(alpha = 0.25f),
),
focused = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
foreground = scheme.onPrimary,
highlight = Color.White.copy(alpha = 0.25f),
),
),
shape = toggleShapeState,
shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
4.dp,
true,
scheme.primary.copy(alpha = 0.3f),
scheme.primary.copy(alpha = 0.35f)
),
hovered = BaseUITheme.Shadow(
6.dp,
true,
scheme.primary.copy(alpha = 0.4f),
scheme.primary.copy(alpha = 0.45f)
),
pressed = BaseUITheme.Shadow(
1.dp,
true,
scheme.primary.copy(alpha = 0.2f),
scheme.primary.copy(alpha = 0.25f)
),
focused = BaseUITheme.Shadow(
4.dp,
true,
scheme.primary.copy(alpha = 0.3f),
scheme.primary.copy(alpha = 0.35f)
),
),
border = 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),
),
padding = togglePadding,
),
unchecked = BaseUITheme.ButtonStyle(
colors = 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,
highlight = highlight,
),
pressed = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerPressed, containerPressed)),
foreground = scheme.onSurface,
highlight = highlight,
),
focused = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
foreground = scheme.onSurface,
highlight = highlight,
),
),
shape = toggleShapeState,
shadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
4.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
hovered = BaseUITheme.Shadow(
6.dp,
true,
shadowColor.copy(alpha = 0.22f),
shadowColor.copy(alpha = 0.26f)
),
pressed = BaseUITheme.Shadow(
1.dp,
true,
shadowColor.copy(alpha = 0.08f),
shadowColor.copy(alpha = 0.1f)
),
focused = BaseUITheme.Shadow(
4.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
),
border = 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),
),
padding = togglePadding,
),
thumb = BaseUITheme.InteractionState(
normal = Color.White,
hovered = Color.White,
pressed = Color.White,
focused = Color.White,
),
thumbShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow(
3.dp,
true,
shadowColor.copy(alpha = 0.2f),
shadowColor.copy(alpha = 0.25f)
),
hovered = BaseUITheme.Shadow(
4.dp,
true,
shadowColor.copy(alpha = 0.28f),
shadowColor.copy(alpha = 0.32f)
),
pressed = BaseUITheme.Shadow(
1.dp,
true,
shadowColor.copy(alpha = 0.12f),
shadowColor.copy(alpha = 0.15f)
),
focused = BaseUITheme.Shadow(
3.dp,
true,
shadowColor.copy(alpha = 0.2f),
shadowColor.copy(alpha = 0.25f)
),
),
),
card = BaseUITheme.CardTheme( card = BaseUITheme.CardTheme(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
@ -662,6 +941,7 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
listRowTile = BaseUITheme.ListRowTheme( listRowTile = BaseUITheme.ListRowTheme(
background = BaseUITheme.AdvancedInteractionState( background = BaseUITheme.AdvancedInteractionState(
normal = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)), hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
pressed = Brush.verticalGradient(listOf(containerPressed, containerPressed)), pressed = Brush.verticalGradient(listOf(containerPressed, containerPressed)),
focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)), focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
@ -679,6 +959,7 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
), ),
shape = BaseUITheme.AdvancedInteractionState( shape = BaseUITheme.AdvancedInteractionState(
normal = RoundedCornerShape(8.dp),
hovered = RoundedCornerShape(8.dp), hovered = RoundedCornerShape(8.dp),
pressed = RoundedCornerShape(8.dp), pressed = RoundedCornerShape(8.dp),
focused = RoundedCornerShape(8.dp), focused = RoundedCornerShape(8.dp),
@ -686,6 +967,7 @@ fun rememberBaseUITheme(): BaseUITheme {
disabled = RoundedCornerShape(8.dp), disabled = RoundedCornerShape(8.dp),
), ),
border = BaseUITheme.AdvancedInteractionState( border = BaseUITheme.AdvancedInteractionState(
normal = BaseUITheme.Border(Color.Transparent, 0.dp),
hovered = BaseUITheme.Border(Color.Transparent, 0.dp), hovered = BaseUITheme.Border(Color.Transparent, 0.dp),
pressed = BaseUITheme.Border(Color.Transparent, 0.dp), pressed = BaseUITheme.Border(Color.Transparent, 0.dp),
focused = BaseUITheme.Border(Color.Transparent, 0.dp), focused = BaseUITheme.Border(Color.Transparent, 0.dp),
@ -693,6 +975,7 @@ fun rememberBaseUITheme(): BaseUITheme {
disabled = BaseUITheme.Border(Color.Transparent, 0.dp), disabled = BaseUITheme.Border(Color.Transparent, 0.dp),
), ),
shadow = BaseUITheme.AdvancedInteractionState( shadow = BaseUITheme.AdvancedInteractionState(
normal = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
hovered = 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), pressed = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
@ -701,6 +984,7 @@ fun rememberBaseUITheme(): BaseUITheme {
), ),
padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
textStyle = BaseUITheme.AdvancedInteractionState( textStyle = BaseUITheme.AdvancedInteractionState(
normal = TextStyle.Default,
hovered = TextStyle.Default, hovered = TextStyle.Default,
pressed = TextStyle.Default, pressed = TextStyle.Default,
focused = TextStyle.Default, focused = TextStyle.Default,
@ -708,6 +992,7 @@ fun rememberBaseUITheme(): BaseUITheme {
disabled = TextStyle.Default, disabled = TextStyle.Default,
), ),
foreground = BaseUITheme.AdvancedInteractionState( foreground = BaseUITheme.AdvancedInteractionState(
normal = scheme.onSurface,
hovered = scheme.onSurface, hovered = scheme.onSurface,
pressed = scheme.onSurface, pressed = scheme.onSurface,
focused = scheme.onSurface, focused = scheme.onSurface,
@ -715,6 +1000,47 @@ fun rememberBaseUITheme(): BaseUITheme {
disabled = scheme.onSurface.copy(alpha = 0.38f), disabled = scheme.onSurface.copy(alpha = 0.38f),
), ),
), ),
dialog = BaseUITheme.DialogTheme(
background = Brush.verticalGradient(
listOf(
scheme.surface,
scheme.surfaceContainerHigh,
)
),
scrim = Color.Black.copy(alpha = 0.42f),
shape = RoundedCornerShape(16.dp),
border = BaseUITheme.Border(border, 0.5.dp),
shadow = BaseUITheme.Shadow(
24.dp,
true,
shadowColor.copy(alpha = 0.3f),
shadowColor.copy(alpha = 0.4f)
),
padding = PaddingValues(24.dp),
),
dropdownMenu = BaseUITheme.DropdownMenuTheme(
background = Brush.verticalGradient(
listOf(
scheme.surfaceContainerHigh,
scheme.surfaceContainer,
)
),
shape = RoundedCornerShape(12.dp),
border = BaseUITheme.Border(border, 0.5.dp),
shadow = BaseUITheme.Shadow(
12.dp,
true,
shadowColor.copy(alpha = 0.2f),
shadowColor.copy(alpha = 0.25f)
),
itemHoverBackground = Brush.verticalGradient(
listOf(containerLighter, containerDarker)
),
itemForeground = scheme.onSurface,
itemHighlight = highlight,
padding = PaddingValues(4.dp),
itemPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
),
) )
} }
@ -766,6 +1092,7 @@ fun invertedButtonStyle(): BaseUITheme.ButtonStyle {
return BaseUITheme.ButtonStyle( return BaseUITheme.ButtonStyle(
colors = BaseUITheme.InteractionState( colors = BaseUITheme.InteractionState(
normal = invertedColors,
hovered = invertedColors, hovered = invertedColors,
pressed = pressedColors, pressed = pressedColors,
focused = invertedColors, focused = invertedColors,

View File

@ -0,0 +1,264 @@
/*
* 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.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import kotlin.math.roundToInt
private val ToggleWidth = 44.dp
private val ToggleHeight = 24.dp
private val ThumbSize = 20.dp
private data class ResolvedToggleState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
)
@Composable
private fun resolveToggleState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedToggleState {
return when {
isPressed -> ResolvedToggleState(
style.colors.pressed,
style.shape.pressed,
style.shadow.pressed,
style.border.pressed,
)
isHovered -> ResolvedToggleState(
style.colors.hovered,
style.shape.hovered,
style.shadow.hovered,
style.border.hovered,
)
else -> ResolvedToggleState(
style.colors.normal,
style.shape.normal,
style.shadow.normal,
style.border.normal,
)
}
}
@Composable
fun Toggle(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
theme: BaseUITheme.ToggleTheme? = null,
) {
val baseTheme = LocalBaseUITheme.current.toggle
val toggleTheme = theme ?: baseTheme
val source = interactionSource ?: remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState()
val isHovered by source.collectIsHoveredAsState()
val style = if (checked) toggleTheme.checked else toggleTheme.unchecked
val resolved = resolveToggleState(style, isPressed, isHovered)
val thumbColor = when {
isPressed -> toggleTheme.thumb.pressed
isHovered -> toggleTheme.thumb.hovered
else -> toggleTheme.thumb.normal
}
val thumbShadowState = when {
isPressed -> toggleTheme.thumbShadow.pressed
isHovered -> toggleTheme.thumbShadow.hovered
else -> toggleTheme.thumbShadow.normal
}
val borderColor = when {
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
else -> resolved.border.color
}
val thumbProgress by animateFloatAsState(
targetValue = if (checked) 1f else 0f,
animationSpec = tween(durationMillis = 200),
label = "thumbProgress",
)
val thumbScale by animateFloatAsState(
targetValue = when {
isPressed -> 0.92f
isHovered -> 1.05f
else -> 1f
},
animationSpec = tween(durationMillis = 150),
label = "thumbScale",
)
val lift = if (isHovered && !isPressed && onCheckedChange != null) (-1).dp else 0.dp
val density = LocalDensity.current
val thumbOffsetXPx by animateDpAsState(
targetValue = with(density) {
val trackWidth = ToggleWidth - ThumbSize
max((thumbProgress * trackWidth.toPx()).toDp() - 4.dp, 2.dp)
},
animationSpec = tween(durationMillis = 200),
label = "thumbOffsetX",
)
Box(
modifier = modifier
.width(ToggleWidth)
.height(ToggleHeight)
.graphicsLayer {
translationY = lift.toPx()
}
.then(
if (resolved.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = resolved.shadow.elevation,
shape = resolved.shape,
ambientColor = resolved.shadow.ambientColor,
spotColor = resolved.shadow.spotColor,
)
} else Modifier
)
.clip(resolved.shape)
.background(resolved.colors.background, resolved.shape)
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
.highlight(resolved.colors.highlight)
.then(
if (onCheckedChange != null) {
Modifier
.hoverable(interactionSource = source, enabled = enabled)
.clickable(
enabled = enabled,
interactionSource = source,
indication = null,
onClick = { onCheckedChange(!checked) },
)
} else {
Modifier
}
),
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.offset(x = thumbOffsetXPx)
.size(ThumbSize)
.graphicsLayer {
scaleX = thumbScale
scaleY = thumbScale
}
.shadow(
elevation = thumbShadowState.elevation,
shape = CircleShape,
ambientColor = thumbShadowState.ambientColor,
spotColor = thumbShadowState.spotColor,
)
.background(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = 0.6f),
thumbColor.copy(alpha = 0.95f),
thumbColor,
),
center = Offset(0f, -0.55f),
radius = 1.1f,
),
shape = CircleShape,
)
.border(
width = 0.8.dp,
brush = Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.55f),
Color.White.copy(alpha = 0.08f),
),
),
shape = CircleShape,
),
)
}
}
@Preview(showBackground = true)
@Composable
private fun TogglePreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Toggle(checked = true, onCheckedChange = {})
Toggle(checked = false, onCheckedChange = {})
Toggle(checked = true, onCheckedChange = null)
Toggle(checked = false, onCheckedChange = null, enabled = false)
}
}
}
}
}

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -33,10 +34,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -64,6 +65,9 @@ import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Check import compose.icons.feathericons.Check
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.DropdownMenu
import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem
import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
@ -105,10 +109,9 @@ fun AdaptiveDropdownBottomSheet(
trigger { expanded = true } trigger { expanded = true }
if (isLargeScreen) { if (isLargeScreen) {
ShadcnDropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
minWidth = menuMinWidth,
) { ) {
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
header() header()
@ -137,26 +140,22 @@ fun AdaptiveDropdownBottomSheet(
) )
} }
val hasSelection = items.any { it.selected } items.forEach { item ->
items
.forEach { item ->
if (query.isNotBlank() && filter != null && !filter(item, query)) { if (query.isNotBlank() && filter != null && !filter(item, query)) {
return@forEach return@forEach
} }
if (item.dividerBefore) { if (item.dividerBefore) {
HorizontalDivider( DropdownMenuDivider()
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
ShadcnDropdownMenuItem( DropdownMenuItem(
item = item, text = item.label,
onClick = { onClick = {
item.onClick() item.onClick()
expanded = false expanded = false
}, },
hasSelection = hasSelection, enabled = item.enabled,
selected = item.selected,
leadingIcon = item.icon,
) )
} }
} }
@ -177,117 +176,6 @@ fun AdaptiveDropdownBottomSheet(
} }
} }
@Composable
private fun ShadcnDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
minWidth: Dp,
content: @Composable () -> Unit,
) {
val shape = RoundedCornerShape(6.dp)
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
offset = DpOffset(x = 0.dp, y = 4.dp),
modifier = Modifier
.width(minWidth)
.shadow(
elevation = 2.dp,
shape = shape,
ambientColor = Color.Black.copy(alpha = 0.06f),
spotColor = Color.Black.copy(alpha = 0.1f),
)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
shape = shape,
)
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = shape,
)
.clip(shape),
) {
Column(
modifier = Modifier.padding(vertical = 4.dp),
) {
content()
}
}
}
@Composable
private fun ShadcnDropdownMenuItem(
item: AdaptiveMenuItem,
onClick: () -> Unit,
hasSelection: Boolean,
) {
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val hoverColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
Row(
modifier = Modifier
.fillMaxWidth()
.hoverable(interactionSource)
.background(
color = if (isHovered && item.enabled) hoverColor else Color.Transparent,
)
.clickable(
enabled = item.enabled,
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (item.icon != null) {
Icon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = if (item.enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
} else if (item.selected) {
Icon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (hasSelection) {
Spacer(modifier = Modifier.size(16.dp))
}
Text(
text = item.label,
style = MaterialTheme.typography.bodyMedium,
color = if (item.enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
modifier = Modifier.weight(1f),
)
if (item.selected && item.icon != null) {
Icon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AdaptiveBottomSheetContent( private fun AdaptiveBottomSheetContent(

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.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare 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.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
@ -80,6 +81,7 @@ fun CollectionDetails(
isFollowing: Boolean = false, isFollowing: Boolean = false,
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, 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 { 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.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch 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.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -140,6 +141,7 @@ fun TrackList(
onBulkDownload: (List<MetadataTrack>) -> Unit = {}, onBulkDownload: (List<MetadataTrack>) -> Unit = {},
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {}, onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {}, onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
currentTrackId: String? = null, currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false, isCurrentTrackPlaying: Boolean = false,
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
@ -433,6 +435,11 @@ fun TrackList(
label = if (isAll) "Play All Next" else "Play $trackCount Next", label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) }, onClick = { onBulkPlayNext(targetTracks) },
), ),
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist,
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
onClick = { onBulkAddToPlaylist(targetTracks) },
),
), ),
trigger = { onClick -> trigger = { onClick ->
GroupIconButton( GroupIconButton(
@ -562,7 +569,7 @@ private fun TrackListRow(
isSelected -> rowTheme.background.selected isSelected -> rowTheme.background.selected
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
isCurrentTrack -> rowTheme.background.focused isCurrentTrack -> rowTheme.background.focused
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) else -> rowTheme.background.normal
} }
val highlightColor = Color.White.copy( val highlightColor = Color.White.copy(
alpha = if (isLight) 0.9f else 0.06f 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.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle 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.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
@ -59,6 +60,7 @@ sealed interface TrackOptionsAction {
data object AddToQueue : TrackOptionsAction data object AddToQueue : TrackOptionsAction
data object RemoveFromQueue : TrackOptionsAction data object RemoveFromQueue : TrackOptionsAction
data object ToggleFavorite : TrackOptionsAction data object ToggleFavorite : TrackOptionsAction
data object AddToPlaylist : TrackOptionsAction
data object Download : TrackOptionsAction data object Download : TrackOptionsAction
data object ToggleBlacklist : TrackOptionsAction data object ToggleBlacklist : TrackOptionsAction
data object Share : 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( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive, icon = Iconsax.IconsaxDirectboxReceive,

View File

@ -20,7 +20,12 @@ package dev.krtirtho.spotube.modules.album
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository 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.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -50,6 +57,8 @@ fun AlbumScreen(albumId: String) {
val audioPlayer: AudioPlayer = koinInject() val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<AlbumViewModel>( val viewModel = koinViewModel<AlbumViewModel>(
key = albumId, key = albumId,
parameters = { parametersOf(albumId) } parameters = { parametersOf(albumId) }
@ -62,6 +71,13 @@ fun AlbumScreen(albumId: String) {
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.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 { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -87,6 +103,10 @@ fun AlbumScreen(albumId: String) {
if (action is TrackOptionsAction.Download) { if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
} }
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
} }
Scaffold( Scaffold(
@ -194,8 +214,26 @@ fun AlbumScreen(albumId: String) {
onBulkPlayNext = { tracks -> onBulkPlayNext = { tracks ->
viewModel.playTracksNext(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.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {} 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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -49,7 +45,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -61,10 +60,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage 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.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService 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.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard 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.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel 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.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -102,6 +108,8 @@ fun ArtistScreen(artistId: String) {
val audioPlayer: AudioPlayer = koinInject() val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<ArtistViewModel>( val viewModel = koinViewModel<ArtistViewModel>(
key = artistId, key = artistId,
parameters = { parametersOf(artistId) } parameters = { parametersOf(artistId) }
@ -114,6 +122,13 @@ fun ArtistScreen(artistId: String) {
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.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 { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -139,6 +154,10 @@ fun ArtistScreen(artistId: String) {
if (action is TrackOptionsAction.Download) { if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
} }
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
} }
Scaffold( Scaffold(
@ -214,6 +233,10 @@ fun ArtistScreen(artistId: String) {
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) }, onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
onBulkPlayNext = { tracks -> viewModel.playTracksNext(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, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
Button(onClick = onRetry) { PrimaryButton(onClick = onRetry) {
Text("Retry") Text("Retry")
} }
} }
@ -452,7 +489,7 @@ private fun ArtistAvatar(
) )
} else { } else {
Icon( Icon(
imageVector = FeatherIcons.User, imageVector = Iconsax.User,
contentDescription = artist.name, contentDescription = artist.name,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(size * 0.4f), modifier = Modifier.size(size * 0.4f),
@ -516,11 +553,11 @@ private fun ArtistHeaderActions(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isSaved) { if (isSaved) {
FilledTonalButton(onClick = onFollowClick) { SecondaryButton(onClick = onFollowClick) {
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
} }
} else { } else {
Button(onClick = onFollowClick) { PrimaryButton(onClick = onFollowClick) {
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
} }
} }
@ -546,15 +583,15 @@ private fun TopTracksHeader(
) )
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
FilledIconButton(onClick = onPlay) { PrimaryIconButton(onClick = onPlay) {
Icon( Icon(
imageVector = FeatherIcons.Play, imageVector = Iconsax.IconsaxPlay,
contentDescription = "Play top tracks", contentDescription = "Play top tracks",
) )
} }
FilledTonalIconButton(onClick = onAddToQueue) { SecondaryIconButton(onClick = onAddToQueue) {
Icon( Icon(
imageVector = FeatherIcons.PlusSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add top tracks to queue", contentDescription = "Add top tracks to queue",
) )
} }

View File

@ -269,6 +269,7 @@ class ArtistViewModel(
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {} 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.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy 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.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import io.github.reactivecircus.cache4k.Cache import io.github.reactivecircus.cache4k.Cache
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -276,4 +277,80 @@ class LibraryRepository(
savedArtistIds.value -= ids.toSet() 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard 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.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -62,6 +68,7 @@ fun LibraryPlaylistsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
var showCreatePlaylist by remember { mutableStateOf(false) }
LaunchedEffect(gridState) { LaunchedEffect(gridState) {
@ -84,8 +91,14 @@ fun LibraryPlaylistsScreen(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedVisibility( AnimatedVisibility(
visible = searchMode, visible = searchMode,
modifier = Modifier.weight(1f),
) { ) {
TextField( TextField(
value = (state as? LibraryPlaylistsState.Data)?.query ?: "", 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)) Spacer(modifier = Modifier.height(12.dp))
when (state) { when (state) {
is LibraryPlaylistsState.Loading -> { 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() 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.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 dev.krtirtho.spotube.core.ui.base.Toggle
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,
)
}
Toggle(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,
)
}
Toggle(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 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.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService 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.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollectionDetails import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay 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.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository 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.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -50,6 +68,8 @@ fun PlaylistScreen(playlistId: String) {
val audioPlayer: AudioPlayer = koinInject() val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<PlaylistViewModel>( val viewModel = koinViewModel<PlaylistViewModel>(
key = playlistId, key = playlistId,
parameters = { parametersOf(playlistId) } parameters = { parametersOf(playlistId) }
@ -61,6 +81,11 @@ fun PlaylistScreen(playlistId: String) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.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) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action) viewModel.handleTrackOptionsAction(track, action)
@ -73,6 +98,10 @@ fun PlaylistScreen(playlistId: String) {
if (action is TrackOptionsAction.Download) { if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
} }
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
} }
Scaffold( Scaffold(
@ -131,6 +160,8 @@ fun PlaylistScreen(playlistId: String) {
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown" val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -166,8 +197,27 @@ fun PlaylistScreen(playlistId: String) {
isFollowing = savedPlaylistIds.contains(playlistId), isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist, onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks", 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, tracks = dataState.tracks,
error = null, error = null,
hasMore = dataState.nextPagination != null, hasMore = dataState.nextPagination != null,
@ -190,8 +240,61 @@ fun PlaylistScreen(playlistId: String) {
onBulkPlayNext = { tracks -> onBulkPlayNext = { tracks ->
viewModel.playTracksNext(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 val savedPlaylistIds
get() = libraryRepository.savedPlaylistIdsFlow get() = libraryRepository.savedPlaylistIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin repository.pluginManager.selectedMetadataPlugin
@ -102,10 +105,15 @@ class PlaylistViewModel(
.distinctUntilChanged() .distinctUntilChanged()
.collect { .collect {
loadInitialData() loadInitialData()
loadCurrentUser()
} }
} }
} }
private suspend fun loadCurrentUser() {
_currentUserId.value = libraryRepository.currentUser()?.id
}
private suspend fun loadInitialData() = runCatching { private suspend fun loadInitialData() = runCatching {
_state.value = PlaylistScreenState.Loading _state.value = PlaylistScreenState.Loading
val playlistInfo = repository.getPlaylistInfo(playlistId) 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) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModelScope.launch { viewModelScope.launch {
when (action) { when (action) {
@ -224,6 +259,7 @@ class PlaylistViewModel(
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
} }
} }
} }

View File

@ -21,16 +21,17 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -46,25 +47,40 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import compose.icons.FeatherIcons import dev.krtirtho.spotube.core.ui.base.Card
import compose.icons.feathericons.Package import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.plugin.components.InstallSection import dev.krtirtho.spotube.modules.plugin.components.InstallSection
import dev.krtirtho.spotube.modules.plugin.components.PluginCard import dev.krtirtho.spotube.modules.plugin.components.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft
import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import kotlinx.coroutines.flow.StateFlow
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_empty_subtitle import spotube.composeapp.generated.resources.plugin_empty_subtitle
import spotube.composeapp.generated.resources.plugin_empty_title import spotube.composeapp.generated.resources.plugin_empty_title
@ -75,6 +91,18 @@ import spotube.composeapp.generated.resources.plugin_installed_count
import spotube.composeapp.generated.resources.plugin_installed_plural import spotube.composeapp.generated.resources.plugin_installed_plural
import spotube.composeapp.generated.resources.plugin_installed_singular import spotube.composeapp.generated.resources.plugin_installed_singular
import spotube.composeapp.generated.resources.plugin_screen_title import spotube.composeapp.generated.resources.plugin_screen_title
import spotube.composeapp.generated.resources.settings_plugins_action_change
import spotube.composeapp.generated.resources.settings_plugins_action_select
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
import spotube.composeapp.generated.resources.settings_plugins_clear
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
import spotube.composeapp.generated.resources.settings_plugins_manage_title
import spotube.composeapp.generated.resources.settings_plugins_no_plugins
import spotube.composeapp.generated.resources.settings_plugins_no_selection
import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -189,6 +217,45 @@ fun PluginScreen(
) )
} }
// ── Default ability plugin selectors ─────────────────
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 4.dp)
) {
PluginAbility.entries.forEachIndexed { index, ability ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(
alpha = 0.5f
),
)
}
val selectedPlugin = state.selectedPlugins[ability]
DefaultAbilityPluginSelector(
ability = ability,
selectedPlugin = selectedPlugin,
state = when (ability) {
PluginAbility.METADATA -> pluginManager.metadataPlugins
PluginAbility.AUDIO -> pluginManager.audioPlugins
PluginAbility.LYRICS -> pluginManager.lyricsPlugins
PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins
},
onSelected = { plugin ->
pluginManager.setSelectedPlugin(ability, plugin)
},
)
}
}
}
}
if (state.plugins.isEmpty()) { if (state.plugins.isEmpty()) {
item { item {
Box( Box(
@ -206,7 +273,7 @@ fun PluginScreen(
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Icon( Icon(
FeatherIcons.Package, Iconsax.IconsaxBox,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
@ -245,8 +312,25 @@ fun PluginScreen(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
) )
} }
items(state.plugins) { plugin -> item {
val isSelected = state.selectedPlugins.containsValue(plugin) Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
state.plugins.forEachIndexed { index, plugin ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(
alpha = 0.5f
),
)
}
val isSelected =
state.selectedPlugins.containsValue(plugin)
val selectedAbility = state.selectedPlugins val selectedAbility = state.selectedPlugins
.entries .entries
.firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id } .firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id }
@ -255,7 +339,10 @@ fun PluginScreen(
activeServices?.get(ability) activeServices?.get(ability)
} }
var requiresAuth by remember(plugin.id, selectedService) { var requiresAuth by remember(
plugin.id,
selectedService
) {
mutableStateOf(false) mutableStateOf(false)
} }
var isLoggedIn by remember(plugin.id, selectedService) { var isLoggedIn by remember(plugin.id, selectedService) {
@ -265,11 +352,13 @@ fun PluginScreen(
LaunchedEffect(plugin.id, selectedService) { LaunchedEffect(plugin.id, selectedService) {
requiresAuth = false requiresAuth = false
isLoggedIn = false isLoggedIn = false
val service = selectedService ?: return@LaunchedEffect val service =
selectedService ?: return@LaunchedEffect
service.use { service.use {
val pluginRequiresAuth = coreAPI.requiresAuthentication val pluginRequiresAuth =
coreAPI.requiresAuthentication
requiresAuth = pluginRequiresAuth requiresAuth = pluginRequiresAuth
if (!pluginRequiresAuth) return@use if (!pluginRequiresAuth) return@use
@ -282,7 +371,9 @@ fun PluginScreen(
PluginCard( PluginCard(
plugin = plugin, plugin = plugin,
isSelected = isSelected, isSelected = isSelected,
onRemove = { scope.launch { pluginManager.removePlugin(plugin) } }, onRemove = {
scope.launch { pluginManager.removePlugin(plugin) }
},
isLoggedIn = isLoggedIn, isLoggedIn = isLoggedIn,
onLogin = if (requiresAuth && selectedService != null) { onLogin = if (requiresAuth && selectedService != null) {
{ {
@ -312,5 +403,195 @@ fun PluginScreen(
} }
} }
} }
}
}
}
}
@Composable
fun DefaultAbilityPluginSelector(
ability: PluginAbility,
state: StateFlow<List<PluginEntry>>,
selectedPlugin: PluginEntry? = null,
onSelected: (PluginEntry?) -> Unit = { },
) {
val plugins by state.collectAsStateWithLifecycle()
val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins)
val menuItems = buildList {
if (plugins.isNotEmpty()) {
plugins.forEach { plugin ->
val isSelected = selectedPlugin?.name == plugin.name
add(
AdaptiveMenuItem(
label = plugin.name,
onClick = { onSelected(plugin) },
selected = isSelected,
)
)
}
} else {
add(
AdaptiveMenuItem(
label = noPluginsText,
onClick = { },
enabled = false,
)
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
color = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
}
) {
Icon(
imageVector = when (ability) {
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
},
contentDescription = stringResource(
Res.string.settings_plugins_plugin_content_description,
ability.displayLabel()
),
modifier = Modifier.padding(8.dp),
tint = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50)
PluginAbility.AUDIO -> Color(0xFF2196F3)
PluginAbility.LYRICS -> Color(0xFFFFC107)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
}
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(
Res.string.settings_plugins_default_ability_title,
ability.displayLabel()
),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (selectedPlugin != null) {
Text(
selectedPlugin.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
} else {
Text(
stringResource(Res.string.settings_plugins_no_selection),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
AdaptiveDropdownBottomSheet(
items = menuItems,
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
header = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
color = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
}
) {
Icon(
imageVector = when (ability) {
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
},
contentDescription = null,
modifier = Modifier.padding(8.dp),
tint = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50)
PluginAbility.AUDIO -> Color(0xFF2196F3)
PluginAbility.LYRICS -> Color(0xFFFFC107)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
}
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(
Res.string.settings_plugins_default_ability_title,
ability.displayLabel()
),
style = MaterialTheme.typography.titleMedium,
)
selectedPlugin?.let {
Text(
it.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
},
trigger = { onClick ->
OutlineButton(onClick = onClick) {
Text(
if (selectedPlugin != null) {
stringResource(Res.string.settings_plugins_action_change)
} else {
stringResource(Res.string.settings_plugins_action_select) + " "
},
)
Icon(
imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
}
},
)
}
}
@Composable
private fun PluginAbility.displayLabel(): String {
return when (this) {
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
}
} }

View File

@ -17,36 +17,34 @@
package dev.krtirtho.spotube.modules.plugin.components package dev.krtirtho.spotube.modules.plugin.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons import dev.krtirtho.spotube.core.ui.base.Card
import compose.icons.feathericons.Download import dev.krtirtho.spotube.core.ui.base.TextField
import compose.icons.feathericons.Link import dev.krtirtho.spotube.core.ui.base.OutlineButton
import compose.icons.feathericons.Upload import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_download import spotube.composeapp.generated.resources.plugin_action_download
@ -65,11 +63,6 @@ internal fun InstallSection(
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@ -80,7 +73,7 @@ internal fun InstallSection(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Icon( Icon(
FeatherIcons.Download, Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
@ -98,7 +91,7 @@ internal fun InstallSection(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
OutlinedTextField( TextField(
value = urlInput, value = urlInput,
onValueChange = onUrlChange, onValueChange = onUrlChange,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -110,22 +103,19 @@ internal fun InstallSection(
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(
FeatherIcons.Link, Iconsax.IconsaxLink,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
}, },
isError = urlError != null, isError = urlError != null,
supportingText = urlError?.let { { Text(it) } }, // supportingText = urlError?.let { { Text(it) } },
// shape = RoundedCornerShape(10.dp),
singleLine = true, singleLine = true,
shape = RoundedCornerShape(10.dp),
textStyle = MaterialTheme.typography.bodySmall
) )
Button( SecondaryIconButton(
onClick = onSubmitUrl, onClick = onSubmitUrl,
enabled = !isLoadingUrl, enabled = !isLoadingUrl,
shape = RoundedCornerShape(10.dp),
modifier = Modifier.height(56.dp)
) { ) {
if (isLoadingUrl) { if (isLoadingUrl) {
CircularProgressIndicator( CircularProgressIndicator(
@ -135,9 +125,8 @@ internal fun InstallSection(
) )
} else { } else {
Icon( Icon(
FeatherIcons.Download, Iconsax.IconsaxImportArrow2Bulk,
contentDescription = stringResource(Res.string.plugin_action_download), contentDescription = stringResource(Res.string.plugin_action_download),
modifier = Modifier.size(16.dp)
) )
} }
} }
@ -146,15 +135,13 @@ internal fun InstallSection(
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// File picker // File picker
OutlinedButton( OutlineButton(
onClick = onPickFile, onClick = onPickFile,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp)
) { ) {
Icon( Icon(
FeatherIcons.Upload, Iconsax.IconsaxExportArrowBulk,
contentDescription = null, contentDescription = stringResource(Res.string.plugin_action_install_from_file)
modifier = Modifier.size(16.dp)
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.plugin_action_install_from_file)) Text(stringResource(Res.string.plugin_action_install_from_file))
@ -162,4 +149,3 @@ internal fun InstallSection(
} }
} }
} }

View File

@ -27,13 +27,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -42,15 +38,17 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import compose.icons.feathericons.Check import dev.krtirtho.spotube.core.ui.base.OutlineButton
import compose.icons.feathericons.Package
import compose.icons.feathericons.Tag
import compose.icons.feathericons.Trash2
import compose.icons.feathericons.User
import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS
import dev.krtirtho.spotube.modules.plugin.PluginAbility import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTag
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_remove import spotube.composeapp.generated.resources.plugin_action_remove
@ -73,20 +71,6 @@ internal fun PluginCard(
onLogin: (() -> Unit)? = null, onLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null, onLogout: (() -> Unit)? = null,
) { ) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
border = BorderStroke(
width = 1.dp,
color = if (isSelected)
MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
else
MaterialTheme.colorScheme.outlineVariant
)
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -103,7 +87,7 @@ internal fun PluginCard(
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Icon( Icon(
FeatherIcons.Package, Iconsax.IconsaxBox,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
@ -140,7 +124,7 @@ internal fun PluginCard(
horizontalArrangement = Arrangement.spacedBy(3.dp) horizontalArrangement = Arrangement.spacedBy(3.dp)
) { ) {
Icon( Icon(
FeatherIcons.Check, Iconsax.IconsaxCheckSquare,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(10.dp), modifier = Modifier.size(10.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
@ -175,7 +159,7 @@ internal fun PluginCard(
horizontalArrangement = Arrangement.spacedBy(3.dp) horizontalArrangement = Arrangement.spacedBy(3.dp)
) { ) {
Icon( Icon(
FeatherIcons.User, Iconsax.User,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(11.dp), modifier = Modifier.size(11.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
@ -192,7 +176,7 @@ internal fun PluginCard(
horizontalArrangement = Arrangement.spacedBy(3.dp) horizontalArrangement = Arrangement.spacedBy(3.dp)
) { ) {
Icon( Icon(
FeatherIcons.Tag, Iconsax.IconsaxTag,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(11.dp), modifier = Modifier.size(11.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
@ -247,12 +231,11 @@ internal fun PluginCard(
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
) )
} else { } else {
IconButton(onClick = onRemove) { GhostIconButton(onClick = onRemove) {
Icon( Icon(
FeatherIcons.Trash2, Iconsax.IconsaxTrash,
contentDescription = stringResource(Res.string.plugin_action_remove), contentDescription = stringResource(Res.string.plugin_action_remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
) )
} }
} }
@ -263,7 +246,7 @@ internal fun PluginCard(
else -> null else -> null
} }
authAction?.let { (action, label) -> authAction?.let { (action, label) ->
TextButton(onClick = action) { OutlineButton(onClick = action) {
Text( Text(
text = stringResource(label), text = stringResource(label),
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge
@ -272,7 +255,6 @@ internal fun PluginCard(
} }
} }
} }
}
} }
@Composable @Composable
@ -284,4 +266,3 @@ private fun PluginAbility.displayLabel(): String {
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
} }
} }

View File

@ -25,18 +25,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -45,16 +38,18 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import dev.krtirtho.spotube.core.ui.base.Card
import androidx.compose.ui.window.DialogProperties import dev.krtirtho.spotube.core.ui.base.OutlineButton
import compose.icons.FeatherIcons import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import compose.icons.feathericons.Database import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import compose.icons.feathericons.Eye
import compose.icons.feathericons.Package
import compose.icons.feathericons.User
import compose.icons.feathericons.Wifi
import dev.krtirtho.spotube.modules.plugin.PluginCapability import dev.krtirtho.spotube.modules.plugin.PluginCapability
import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxEye
import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare
import dev.krtirtho.spotube.resources.iconsax.PhosphorDatabaseDuotone
import dev.krtirtho.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_permissions_api_diff import spotube.composeapp.generated.resources.plugin_permissions_api_diff
@ -84,48 +79,28 @@ fun PluginPermissionDialog(
onConfirm: (() -> Unit)? = null, onConfirm: (() -> Unit)? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
Dialog( ThemedDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) title = {
) {
Card(
modifier = Modifier
.widthIn(min = 320.dp, max = 480.dp)
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
.padding(24.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp) horizontalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
Surface( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(12.dp))
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center
) { ) {
Box(contentAlignment = Alignment.Center) {
Icon( Icon(
imageVector = FeatherIcons.Package, imageVector = Iconsax.IconsaxBoxAdd,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
} Column(modifier = Modifier.weight(1f)) {
Column {
Text( Text(
title, title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -143,10 +118,11 @@ fun PluginPermissionDialog(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 8.dp)
) { ) {
Icon( Icon(
FeatherIcons.User, Iconsax.User,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(13.dp), modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
@ -165,7 +141,27 @@ fun PluginPermissionDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
},
actions = {
OutlineButton(onClick = onDismiss) {
Text(
if (confirmLabel == null) {
stringResource(Res.string.settings_action_close)
} else {
stringResource(Res.string.settings_action_cancel)
}
)
}
if (confirmLabel != null && onConfirm != null) {
PrimaryButton(onClick = onConfirm) {
Text(confirmLabel)
}
}
},
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (pluginInfo.description.isNotBlank()) { if (pluginInfo.description.isNotBlank()) {
Text( Text(
pluginInfo.description, pluginInfo.description,
@ -175,22 +171,8 @@ fun PluginPermissionDialog(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
}
}
HorizontalDivider() Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)
) {
Text( Text(
message, message,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -225,41 +207,6 @@ fun PluginPermissionDialog(
} }
} }
} }
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End)
) {
OutlinedButton(
onClick = onDismiss,
shape = RoundedCornerShape(10.dp)
) {
Text(
if (confirmLabel == null) {
stringResource(Res.string.settings_action_close)
} else {
stringResource(Res.string.settings_action_cancel)
}
)
}
if (confirmLabel != null && onConfirm != null) {
Button(
onClick = onConfirm,
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text(confirmLabel)
}
}
}
}
}
} }
} }
@ -268,10 +215,8 @@ private fun InstalledComparisonCard(
installedPlugin: PluginEntry, installedPlugin: PluginEntry,
incomingPlugin: PluginEntry, incomingPlugin: PluginEntry,
) { ) {
Surface( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.45f)
) { ) {
Column( Column(
modifier = Modifier.padding(14.dp), modifier = Modifier.padding(14.dp),
@ -328,28 +273,29 @@ private fun VersionStat(label: String, value: String) {
private fun CapabilityRow(capability: PluginCapability) { private fun CapabilityRow(capability: PluginCapability) {
val (icon, label, description) = when (capability) { val (icon, label, description) = when (capability) {
PluginCapability.PERSISTENT_STORAGE -> Triple( PluginCapability.PERSISTENT_STORAGE -> Triple(
FeatherIcons.Database, Iconsax.PhosphorDatabaseDuotone,
stringResource(Res.string.plugin_permissions_capability_storage_title), stringResource(Res.string.plugin_permissions_capability_storage_title),
stringResource(Res.string.plugin_permissions_capability_storage_desc) stringResource(Res.string.plugin_permissions_capability_storage_desc)
) )
PluginCapability.NETWORK_REQUESTS -> Triple( PluginCapability.NETWORK_REQUESTS -> Triple(
FeatherIcons.Wifi, Iconsax.IconsaxWifiSquare,
stringResource(Res.string.plugin_permissions_capability_network_title), stringResource(Res.string.plugin_permissions_capability_network_title),
stringResource(Res.string.plugin_permissions_capability_network_desc) stringResource(Res.string.plugin_permissions_capability_network_desc)
) )
PluginCapability.WEBVIEW -> Triple( PluginCapability.WEBVIEW -> Triple(
FeatherIcons.Eye, Iconsax.IconsaxEye,
stringResource(Res.string.plugin_permissions_capability_webview_title), stringResource(Res.string.plugin_permissions_capability_webview_title),
stringResource(Res.string.plugin_permissions_capability_webview_desc) stringResource(Res.string.plugin_permissions_capability_webview_desc)
) )
} }
Card(
modifier = Modifier.fillMaxWidth(),
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .padding(12.dp),
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp) horizontalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
@ -380,4 +326,5 @@ private fun CapabilityRow(capability: PluginCapability) {
) )
} }
} }
}
} }

View File

@ -20,7 +20,12 @@ package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel 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.jetbrains.compose.resources.DrawableResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -53,6 +61,8 @@ fun SavedTracksScreen() {
val audioPlayer: AudioPlayer = koinInject() val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<SavedTracksViewModel>( val viewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID, key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() } parameters = { parametersOf() }
@ -63,6 +73,13 @@ fun SavedTracksScreen() {
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.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 { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -88,6 +105,10 @@ fun SavedTracksScreen() {
if (action is TrackOptionsAction.Download) { if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
} }
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
} }
Scaffold( Scaffold(
@ -187,8 +208,26 @@ fun SavedTracksScreen() {
onBulkPlayNext = { tracks -> onBulkPlayNext = { tracks ->
viewModel.playTracksNext(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.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {} 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel 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.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
@ -115,10 +119,18 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val audioPlayerQueue: AudioPlayerQueue = koinInject() val audioPlayerQueue: AudioPlayerQueue = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val selectedType = state.selectedSearchType val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() 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 val focusManager = LocalFocusManager.current
@ -175,6 +187,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
shareService.share(uri, track.title) 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) }, onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = ::bulkAddToQueue, onBulkAddToQueue = ::bulkAddToQueue,
onBulkPlayNext = ::bulkPlayNext, onBulkPlayNext = ::bulkPlayNext,
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
) )
@ -311,6 +331,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = ::bulkAddToQueue, onBulkAddToQueue = ::bulkAddToQueue,
onBulkPlayNext = ::bulkPlayNext, onBulkPlayNext = ::bulkPlayNext,
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
modifier = Modifier modifier = Modifier
.fillMaxSize(), .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, onBulkDownload: (List<MetadataTrack>) -> Unit,
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (query.isBlank()) { if (query.isBlank()) {
@ -621,6 +660,7 @@ private fun SearchAllTab(
onBulkDownload = onBulkDownload, onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
headerContent = { headerContent = {
if (showTracks) { if (showTracks) {
@ -704,6 +744,7 @@ private fun SearchTracksTab(
onBulkDownload: (List<MetadataTrack>) -> Unit, onBulkDownload: (List<MetadataTrack>) -> Unit,
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (query.isBlank()) { if (query.isBlank()) {
@ -729,6 +770,7 @@ private fun SearchTracksTab(
onBulkDownload = onBulkDownload, onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
simplified = true, simplified = true,
modifier = modifier, modifier = modifier,
) )

View File

@ -37,7 +37,6 @@ import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.plugin.PluginManager
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.modules.settings.sections.appearanceSection import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
import dev.krtirtho.spotube.modules.settings.sections.cacheSection import dev.krtirtho.spotube.modules.settings.sections.cacheSection
@ -54,9 +53,8 @@ import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen(pluginManager: PluginManager, settingsViewModel: SettingsViewModel) { fun SettingsScreen(settingsViewModel: SettingsViewModel) {
val navigatorCommands = koinInject<NavigationCommands>() val navigatorCommands = koinInject<NavigationCommands>()
val pluginState by pluginManager.state.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
val platformType = remember { getPlatform().type } val platformType = remember { getPlatform().type }
val isDesktopPlatform = platformType == PlatformType.Windows || val isDesktopPlatform = platformType == PlatformType.Windows ||
@ -90,8 +88,6 @@ fun SettingsScreen(pluginManager: PluginManager, settingsViewModel: SettingsView
contentPadding = contentPadding, contentPadding = contentPadding,
) { ) {
pluginsSection( pluginsSection(
pluginManager = pluginManager,
pluginState = pluginState,
navigatorCommands = navigatorCommands, navigatorCommands = navigatorCommands,
) )
if (settingsState != null) { if (settingsState != null) {

View File

@ -24,18 +24,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -46,8 +41,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.Radio
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.base.Toggle
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -64,7 +67,7 @@ internal fun SwitchSettingCard(
subtitle = subtitle, subtitle = subtitle,
icon = icon, icon = icon,
trailingContent = { trailingContent = {
Switch( Toggle(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
) )
@ -107,8 +110,13 @@ internal fun <T> SelectionSettingCard(
) )
}, },
trigger = { onClick -> trigger = { onClick ->
TextButton(onClick = onClick) { OutlineButton(onClick = onClick) {
Text(optionLabel(selectedOption)) Text(optionLabel(selectedOption))
Icon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
} }
}, },
filter = filter, filter = filter,
@ -122,16 +130,18 @@ internal fun <T> SelectionSettingCard(
) )
if (!isWideLayout && isDialogOpen) { if (!isWideLayout && isDialogOpen) {
AlertDialog( ThemedDialog(
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = { title = {
Text(dialogTitle) Text(dialogTitle, style = MaterialTheme.typography.titleLarge)
},
actions = {
OutlineButton(onClick = { isDialogOpen = false }) {
Text(stringResource(Res.string.settings_action_close))
}
}, },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
options.forEach { option -> options.forEach { option ->
val isSelected = option == selectedOption val isSelected = option == selectedOption
Row( Row(
@ -146,7 +156,7 @@ internal fun <T> SelectionSettingCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
RadioButton( Radio(
selected = isSelected, selected = isSelected,
onClick = { onClick = {
onOptionSelected(option) onOptionSelected(option)
@ -160,14 +170,8 @@ internal fun <T> SelectionSettingCard(
} }
} }
} }
},
confirmButton = {
TextButton(onClick = { isDialogOpen = false }) {
Text(stringResource(Res.string.settings_action_close))
} }
} }
)
}
} }
} }
@ -234,12 +238,27 @@ internal fun TextInputSettingCard(
val normalizedValue = normalize(draft) val normalizedValue = normalize(draft)
val errorMessage = validate(normalizedValue) val errorMessage = validate(normalizedValue)
AlertDialog( ThemedDialog(
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = { title = {
Text(dialogTitle) Text(dialogTitle, style = MaterialTheme.typography.titleLarge)
}, },
text = { actions = {
PrimaryButton(
onClick = {
if (errorMessage == null) {
onValueSaved(normalizedValue)
isDialogOpen = false
}
}
) {
Text(stringResource(Res.string.settings_action_save))
}
OutlineButton(onClick = { isDialogOpen = false }) {
Text(stringResource(Res.string.settings_action_cancel))
}
},
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
dialogDescription?.let { dialogDescription?.let {
Text( Text(
@ -264,25 +283,7 @@ internal fun TextInputSettingCard(
singleLine = true, singleLine = true,
) )
} }
},
confirmButton = {
TextButton(
onClick = {
if (errorMessage == null) {
onValueSaved(normalizedValue)
isDialogOpen = false
} }
} }
) {
Text(stringResource(Res.string.settings_action_save))
}
},
dismissButton = {
TextButton(onClick = { isDialogOpen = false }) {
Text(stringResource(Res.string.settings_action_cancel))
}
}
)
}
} }
} }

View File

@ -17,26 +17,11 @@
package dev.krtirtho.spotube.modules.settings.components package dev.krtirtho.spotube.modules.settings.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import dev.krtirtho.spotube.core.ui.base.ListRowTile
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/** /**
* Reusable setting card item component for consistent styling * Reusable setting card item component for consistent styling
@ -48,54 +33,32 @@ fun SettingCardItem(
title: String, title: String,
subtitle: String? = null, subtitle: String? = null,
icon: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null,
trailingContent: (@Composable RowScope.() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Card( ListRowTile(
modifier = modifier selected = false,
.fillMaxWidth() enabled = enabled,
.padding(horizontal = 8.dp, vertical = 4.dp) onClick = onClick,
.clip(RoundedCornerShape(12.dp)) modifier = modifier,
.clickable(onClick = onClick, enabled = enabled) leading = icon,
.then(if (!enabled) Modifier.alpha(0.5f) else Modifier), title = {
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
border = BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
icon()
}
Column(modifier = Modifier.weight(1f)) {
Text( Text(
title, title,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
if (subtitle != null) { },
subtitle = subtitle?.let {
{
Text( Text(
subtitle, it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.Gray, color = MaterialTheme.colorScheme.onSurfaceVariant
modifier = Modifier.padding(top = 4.dp)
) )
} }
} },
trailing = trailingContent,
trailingContent?.invoke(this) )
}
}
} }

View File

@ -27,19 +27,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -53,12 +48,19 @@ import compose.icons.FeatherIcons
import compose.icons.feathericons.Droplet import compose.icons.feathericons.Droplet
import compose.icons.feathericons.Monitor import compose.icons.feathericons.Monitor
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.Radio
import dev.krtirtho.spotube.modules.settings.AccentColors import dev.krtirtho.spotube.modules.settings.AccentColors
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
@ -67,8 +69,9 @@ internal fun LazyListScope.appearanceSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_appearance) settingsSectionHeader(Res.string.settings_section_appearance)
settingsSectionCard(
item { items = listOf(
{
SelectionSettingCard( SelectionSettingCard(
title = stringResource(Res.string.settings_theme_title), title = stringResource(Res.string.settings_theme_title),
subtitle = stringResource( subtitle = stringResource(
@ -76,7 +79,7 @@ internal fun LazyListScope.appearanceSection(
settings.theme.displayLabel() settings.theme.displayLabel()
), ),
icon = { icon = {
SettingsItemIcon(FeatherIcons.Monitor, stringResource(Res.string.settings_theme_title)) SettingsItemIcon(Iconsax.IconsaxColorSwatch, stringResource(Res.string.settings_theme_title))
}, },
selectedOption = settings.theme, selectedOption = settings.theme,
options = Theme.entries, options = Theme.entries,
@ -87,13 +90,12 @@ internal fun LazyListScope.appearanceSection(
} }
} }
) )
} },
{
item {
AccentColorSettingCard( AccentColorSettingCard(
selectedAccent = settings.accentColor, selectedAccent = settings.accentColor,
icon = { icon = {
SettingsItemIcon(FeatherIcons.Droplet, stringResource(Res.string.settings_accent_title)) SettingsItemIcon(Iconsax.IconsaxColorsSquare, stringResource(Res.string.settings_accent_title))
}, },
onColorSaved = { accent -> onColorSaved = { accent ->
settingsViewModel.updateSettings { settingsViewModel.updateSettings {
@ -101,7 +103,9 @@ internal fun LazyListScope.appearanceSection(
} }
} }
) )
} },
)
)
} }
@Composable @Composable
@ -155,19 +159,26 @@ private fun AccentColorSettingCard(
if (isDialogOpen) { if (isDialogOpen) {
var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) } var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) }
AlertDialog( ThemedDialog(
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = { title = {
Text(stringResource(Res.string.settings_accent_dialog_title)) Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge)
}, },
text = { actions = {
Column( PrimaryButton(
modifier = Modifier onClick = {
.fillMaxWidth() onColorSaved(draftAccent)
.heightIn(max = 420.dp) isDialogOpen = false
.verticalScroll(rememberScrollState()), }
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text(stringResource(Res.string.settings_action_save))
}
OutlineButton(onClick = { isDialogOpen = false }) {
Text(stringResource(Res.string.settings_action_cancel))
}
},
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
text = stringResource(Res.string.settings_accent_dialog_description), text = stringResource(Res.string.settings_accent_dialog_description),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -204,7 +215,7 @@ private fun AccentColorSettingCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
RadioButton( Radio(
selected = option == draftAccent, selected = option == draftAccent,
onClick = { draftAccent = option } onClick = { draftAccent = option }
) )
@ -221,23 +232,7 @@ private fun AccentColorSettingCard(
} }
} }
} }
},
confirmButton = {
TextButton(
onClick = {
onColorSaved(draftAccent)
isDialogOpen = false
} }
) {
Text(stringResource(Res.string.settings_action_save))
}
},
dismissButton = {
TextButton(onClick = { isDialogOpen = false }) {
Text(stringResource(Res.string.settings_action_cancel))
}
}
)
} }
} }

View File

@ -19,19 +19,22 @@ package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Folder import compose.icons.feathericons.Folder
import compose.icons.feathericons.HardDrive import compose.icons.feathericons.HardDrive
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxStopCircle
import io.github.vinceglb.filekit.path import io.github.vinceglb.filekit.path
import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -41,14 +44,15 @@ internal fun LazyListScope.cacheSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_caching) settingsSectionHeader(Res.string.settings_section_caching)
settingsSectionCard(
item { items = listOf(
{
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_enable_music_caching_title), title = stringResource(Res.string.settings_enable_music_caching_title),
subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle), subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.HardDrive, Iconsax.IconsaxDocumentDownload,
stringResource(Res.string.settings_enable_music_caching_title) stringResource(Res.string.settings_enable_music_caching_title)
) )
}, },
@ -59,9 +63,8 @@ internal fun LazyListScope.cacheSection(
} }
} }
) )
} },
{
item {
CacheFolderSettingCard( CacheFolderSettingCard(
enabled = settings.enableMusicCaching, enabled = settings.enableMusicCaching,
folder = settings.cacheFolder, folder = settings.cacheFolder,
@ -71,9 +74,8 @@ internal fun LazyListScope.cacheSection(
} }
} }
) )
} },
{
item {
val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_whole_number = stringResource(Res.string.settings_error_whole_number)
val error_cache_range = stringResource(Res.string.settings_error_cache_size_range) val error_cache_range = stringResource(Res.string.settings_error_cache_size_range)
@ -89,7 +91,7 @@ internal fun LazyListScope.cacheSection(
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.HardDrive, Iconsax.IconsaxStopCircle,
stringResource(Res.string.settings_cache_size_limit_title) stringResource(Res.string.settings_cache_size_limit_title)
) )
}, },
@ -117,7 +119,9 @@ internal fun LazyListScope.cacheSection(
} }
} }
) )
} },
)
)
} }
@Composable @Composable
@ -142,17 +146,17 @@ private fun CacheFolderSettingCard(
} ?: stringResource(Res.string.settings_cache_folder_default), } ?: stringResource(Res.string.settings_cache_folder_default),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.Folder, Iconsax.IconsaxFolder,
stringResource(Res.string.settings_cache_folder_title) stringResource(Res.string.settings_cache_folder_title)
) )
}, },
trailingContent = { trailingContent = {
IconButton( GhostIconButton(
onClick = { pickerLauncher.launch() }, onClick = { pickerLauncher.launch() },
enabled = enabled, enabled = enabled,
) { ) {
Icon( Icon(
FeatherIcons.Folder, Iconsax.IconsaxFolderAdd,
contentDescription = stringResource(Res.string.settings_cache_folder_title) contentDescription = stringResource(Res.string.settings_cache_folder_title)
) )
} }

View File

@ -18,13 +18,13 @@
package dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import compose.icons.FeatherIcons
import compose.icons.feathericons.Activity
import compose.icons.feathericons.Minimize2
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2
import dev.krtirtho.spotube.resources.iconsax.LineDiscord
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.desktopSection( internal fun LazyListScope.desktopSection(
@ -32,14 +32,15 @@ internal fun LazyListScope.desktopSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_desktop) settingsSectionHeader(Res.string.settings_section_desktop)
settingsSectionCard(
item { items = listOf(
{
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_desktop_minimize_title), title = stringResource(Res.string.settings_desktop_minimize_title),
subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle), subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.Minimize2, Iconsax.IconsaxImportArrow2,
stringResource(Res.string.settings_desktop_minimize_title) stringResource(Res.string.settings_desktop_minimize_title)
) )
}, },
@ -50,15 +51,14 @@ internal fun LazyListScope.desktopSection(
} }
} }
) )
} },
{
item {
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_desktop_discord_title), title = stringResource(Res.string.settings_desktop_discord_title),
subtitle = stringResource(Res.string.settings_desktop_discord_subtitle), subtitle = stringResource(Res.string.settings_desktop_discord_subtitle),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.Activity, Iconsax.LineDiscord,
stringResource(Res.string.settings_desktop_discord_title) stringResource(Res.string.settings_desktop_discord_title)
) )
}, },
@ -69,6 +69,8 @@ internal fun LazyListScope.desktopSection(
} }
} }
) )
} },
)
)
} }

View File

@ -24,14 +24,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@ -42,20 +37,44 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import compose.icons.feathericons.Disc import dev.krtirtho.spotube.core.ui.base.OutlineButton
import compose.icons.feathericons.Folder import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import compose.icons.feathericons.PlusSquare import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import compose.icons.feathericons.Sliders
import compose.icons.feathericons.Trash2
import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
import io.github.vinceglb.filekit.path import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting
import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher
import io.github.vinceglb.filekit.path
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_action_cancel
import spotube.composeapp.generated.resources.settings_action_save
import spotube.composeapp.generated.resources.settings_download_folder_current
import spotube.composeapp.generated.resources.settings_download_folder_default
import spotube.composeapp.generated.resources.settings_download_folder_title
import spotube.composeapp.generated.resources.settings_download_format_subtitle_current
import spotube.composeapp.generated.resources.settings_download_format_title
import spotube.composeapp.generated.resources.settings_download_quality_title
import spotube.composeapp.generated.resources.settings_local_media_folders_add_action
import spotube.composeapp.generated.resources.settings_local_media_folders_description
import spotube.composeapp.generated.resources.settings_local_media_folders_manage
import spotube.composeapp.generated.resources.settings_local_media_folders_none_added
import spotube.composeapp.generated.resources.settings_local_media_folders_remove_action
import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_current
import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty
import spotube.composeapp.generated.resources.settings_local_media_folders_title
import spotube.composeapp.generated.resources.settings_section_downloads
import spotube.composeapp.generated.resources.settings_subtitle_current
internal fun LazyListScope.downloadsSection( internal fun LazyListScope.downloadsSection(
settings: UserSettings, settings: UserSettings,
@ -68,8 +87,9 @@ internal fun LazyListScope.downloadsSection(
) )
settingsSectionHeader(Res.string.settings_section_downloads) settingsSectionHeader(Res.string.settings_section_downloads)
settingsSectionCard(
item { items = listOf(
{
DownloadFolderSettingCard( DownloadFolderSettingCard(
folder = settings.overloadedDownloadFolder, folder = settings.overloadedDownloadFolder,
onFolderSelected = { folder -> onFolderSelected = { folder ->
@ -78,9 +98,8 @@ internal fun LazyListScope.downloadsSection(
} }
} }
) )
} },
{
item {
LocalMediaFoldersSettingCard( LocalMediaFoldersSettingCard(
folders = settings.localMediaFolders, folders = settings.localMediaFolders,
onFoldersSaved = { folders -> onFoldersSaved = { folders ->
@ -89,9 +108,8 @@ internal fun LazyListScope.downloadsSection(
} }
} }
) )
} },
{
item {
SelectionSettingCard( SelectionSettingCard(
title = stringResource(Res.string.settings_download_format_title), title = stringResource(Res.string.settings_download_format_title),
subtitle = stringResource( subtitle = stringResource(
@ -99,7 +117,7 @@ internal fun LazyListScope.downloadsSection(
settings.downloadMusicFormat.displayLabel() settings.downloadMusicFormat.displayLabel()
), ),
icon = { icon = {
SettingsItemIcon(FeatherIcons.Disc, stringResource(Res.string.settings_download_format_title)) SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_download_format_title))
}, },
selectedOption = settings.downloadMusicFormat, selectedOption = settings.downloadMusicFormat,
options = downloadFormats, options = downloadFormats,
@ -113,9 +131,8 @@ internal fun LazyListScope.downloadsSection(
} }
} }
) )
} },
{
item {
SelectionSettingCard( SelectionSettingCard(
title = stringResource(Res.string.settings_download_quality_title), title = stringResource(Res.string.settings_download_quality_title),
subtitle = stringResource( subtitle = stringResource(
@ -123,7 +140,7 @@ internal fun LazyListScope.downloadsSection(
settings.downloadMusicQuality.displayLabel() settings.downloadMusicQuality.displayLabel()
), ),
icon = { icon = {
SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_download_quality_title)) SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_download_quality_title))
}, },
selectedOption = settings.downloadMusicQuality, selectedOption = settings.downloadMusicQuality,
options = downloadQualities, options = downloadQualities,
@ -134,7 +151,9 @@ internal fun LazyListScope.downloadsSection(
} }
} }
) )
} },
)
)
} }
@Composable @Composable
@ -157,12 +176,12 @@ private fun DownloadFolderSettingCard(
stringResource(Res.string.settings_download_folder_current, it) stringResource(Res.string.settings_download_folder_current, it)
} ?: stringResource(Res.string.settings_download_folder_default), } ?: stringResource(Res.string.settings_download_folder_default),
icon = { icon = {
SettingsItemIcon(FeatherIcons.Folder, stringResource(Res.string.settings_download_folder_title)) SettingsItemIcon(Iconsax.IconsaxDirectboxReceive, stringResource(Res.string.settings_download_folder_title))
}, },
trailingContent = { trailingContent = {
IconButton(onClick = { pickerLauncher.launch() }) { GhostIconButton(onClick = { pickerLauncher.launch() }) {
Icon( Icon(
FeatherIcons.Folder, imageVector = Iconsax.IconsaxFolderAdd,
contentDescription = stringResource(Res.string.settings_download_folder_title) contentDescription = stringResource(Res.string.settings_download_folder_title)
) )
} }
@ -194,12 +213,12 @@ private fun LocalMediaFoldersSettingCard(
subtitle = subtitlePreview, subtitle = subtitlePreview,
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.Folder, Iconsax.IconsaxFolder,
stringResource(Res.string.settings_local_media_folders_title), stringResource(Res.string.settings_local_media_folders_title),
) )
}, },
trailingContent = { trailingContent = {
TextButton(onClick = { isDialogOpen = true }) { OutlineButton(onClick = { isDialogOpen = true }) {
Text(stringResource(Res.string.settings_local_media_folders_manage)) Text(stringResource(Res.string.settings_local_media_folders_manage))
} }
}, },
@ -235,28 +254,41 @@ private fun LocalMediaFoldersDialog(
} }
} }
AlertDialog( ThemedDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = {
Text(stringResource(Res.string.settings_local_media_folders_title)) Text(
text = stringResource(Res.string.settings_local_media_folders_title),
style = MaterialTheme.typography.titleLarge,
)
}, },
text = { actions = {
Column( PrimaryButton(
verticalArrangement = Arrangement.spacedBy(12.dp), onClick = {
modifier = Modifier.verticalScroll(rememberScrollState()), onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct())
onDismiss()
}
) { ) {
Text(stringResource(Res.string.settings_action_save))
}
OutlineButton(onClick = onDismiss) {
Text(stringResource(Res.string.settings_action_cancel))
}
},
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
text = stringResource(Res.string.settings_local_media_folders_description), text = stringResource(Res.string.settings_local_media_folders_description),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
TextButton(onClick = { pickerLauncher.launch() }) { OutlineButton(onClick = { pickerLauncher.launch() }) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(FeatherIcons.PlusSquare, contentDescription = null) Icon(Iconsax.IconsaxAddSquare, contentDescription = null)
Text(stringResource(Res.string.settings_local_media_folders_add_action)) Text(stringResource(Res.string.settings_local_media_folders_add_action))
} }
} }
@ -283,7 +315,7 @@ private fun LocalMediaFoldersDialog(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
IconButton( GhostIconButton(
onClick = { onClick = {
if (index in draftFolders.indices) { if (index in draftFolders.indices) {
draftFolders.removeAt(index) draftFolders.removeAt(index)
@ -291,7 +323,7 @@ private fun LocalMediaFoldersDialog(
} }
) { ) {
Icon( Icon(
imageVector = FeatherIcons.Trash2, imageVector = Iconsax.IconsaxTrash,
contentDescription = stringResource( contentDescription = stringResource(
Res.string.settings_local_media_folders_remove_action, Res.string.settings_local_media_folders_remove_action,
), ),
@ -302,22 +334,6 @@ private fun LocalMediaFoldersDialog(
} }
} }
} }
},
confirmButton = {
TextButton(
onClick = {
onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct())
onDismiss()
} }
) {
Text(stringResource(Res.string.settings_action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.settings_action_cancel))
}
}
)
} }

View File

@ -27,6 +27,9 @@ import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.SupportedLanguages import dev.krtirtho.spotube.modules.settings.SupportedLanguages
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe
import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.languageRegionSection( internal fun LazyListScope.languageRegionSection(
@ -34,8 +37,9 @@ internal fun LazyListScope.languageRegionSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_language_region) settingsSectionHeader(Res.string.settings_section_language_region)
settingsSectionCard(
item { items = listOf(
{
SelectionSettingCard( SelectionSettingCard(
title = stringResource(Res.string.settings_language_title), title = stringResource(Res.string.settings_language_title),
subtitle = stringResource( subtitle = stringResource(
@ -44,7 +48,7 @@ internal fun LazyListScope.languageRegionSection(
), ),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.Globe, Iconsax.IconsaxLanguageSquare,
stringResource(Res.string.settings_language_title) stringResource(Res.string.settings_language_title)
) )
}, },
@ -60,9 +64,8 @@ internal fun LazyListScope.languageRegionSection(
}, },
filter = { item, query -> item.label.contains(query, ignoreCase = true) }, filter = { item, query -> item.label.contains(query, ignoreCase = true) },
) )
} },
{
item {
SelectionSettingCard( SelectionSettingCard(
title = stringResource(Res.string.settings_country_title), title = stringResource(Res.string.settings_country_title),
subtitle = stringResource( subtitle = stringResource(
@ -71,7 +74,7 @@ internal fun LazyListScope.languageRegionSection(
), ),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.MapPin, Iconsax.IconsaxGlobe,
stringResource(Res.string.settings_country_title) stringResource(Res.string.settings_country_title)
) )
}, },
@ -87,6 +90,8 @@ internal fun LazyListScope.languageRegionSection(
}, },
filter = { item, query -> item.label.contains(query, ignoreCase = true) }, filter = { item, query -> item.label.contains(query, ignoreCase = true) },
) )
} },
)
)
} }

View File

@ -30,6 +30,12 @@ import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
import dev.krtirtho.spotube.resources.iconsax.CustomServer
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow
import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.playbackSection( internal fun LazyListScope.playbackSection(
@ -43,8 +49,9 @@ internal fun LazyListScope.playbackSection(
) )
settingsSectionHeader(Res.string.settings_section_playback) settingsSectionHeader(Res.string.settings_section_playback)
settingsSectionCard(
item { items = listOf(
{
SelectionSettingCard( SelectionSettingCard(
title = stringResource(Res.string.settings_streaming_format_title), title = stringResource(Res.string.settings_streaming_format_title),
subtitle = stringResource( subtitle = stringResource(
@ -52,7 +59,7 @@ internal fun LazyListScope.playbackSection(
settings.streamingMusicFormat.displayLabel() settings.streamingMusicFormat.displayLabel()
), ),
icon = { icon = {
SettingsItemIcon(FeatherIcons.Radio, stringResource(Res.string.settings_streaming_format_title)) SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title))
}, },
selectedOption = settings.streamingMusicFormat, selectedOption = settings.streamingMusicFormat,
options = streamingFormats, options = streamingFormats,
@ -66,9 +73,8 @@ internal fun LazyListScope.playbackSection(
} }
} }
) )
} },
{
item {
SelectionSettingCard( SelectionSettingCard(
title = stringResource(Res.string.settings_streaming_quality_title), title = stringResource(Res.string.settings_streaming_quality_title),
subtitle = stringResource( subtitle = stringResource(
@ -76,7 +82,7 @@ internal fun LazyListScope.playbackSection(
settings.streamingMusicQuality.displayLabel() settings.streamingMusicQuality.displayLabel()
), ),
icon = { icon = {
SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_streaming_quality_title)) SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title))
}, },
selectedOption = settings.streamingMusicQuality, selectedOption = settings.streamingMusicQuality,
options = streamingQualities, options = streamingQualities,
@ -87,15 +93,14 @@ internal fun LazyListScope.playbackSection(
} }
} }
) )
} },
{
item {
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_enable_endless_playback_title), title = stringResource(Res.string.settings_enable_endless_playback_title),
subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle), subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.Repeat, Iconsax.IconsaxRepeatArrow,
stringResource(Res.string.settings_enable_endless_playback_title) stringResource(Res.string.settings_enable_endless_playback_title)
) )
}, },
@ -106,14 +111,13 @@ internal fun LazyListScope.playbackSection(
} }
} }
) )
} },
{
item {
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_enable_connect_title), title = stringResource(Res.string.settings_enable_connect_title),
subtitle = stringResource(Res.string.settings_enable_connect_subtitle), subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
icon = { icon = {
SettingsItemIcon(FeatherIcons.Cast, stringResource(Res.string.settings_enable_connect_title)) SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
}, },
checked = settings.enableConnect, checked = settings.enableConnect,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
@ -122,9 +126,8 @@ internal fun LazyListScope.playbackSection(
} }
} }
) )
} },
{
item {
val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_whole_number = stringResource(Res.string.settings_error_whole_number)
val error_port_range = stringResource(Res.string.settings_error_port_range) val error_port_range = stringResource(Res.string.settings_error_port_range)
@ -135,7 +138,7 @@ internal fun LazyListScope.playbackSection(
settings.playbackProxyServerPort settings.playbackProxyServerPort
), ),
icon = { icon = {
SettingsItemIcon(FeatherIcons.Server, stringResource(Res.string.settings_playback_port_title)) SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title))
}, },
value = settings.playbackProxyServerPort.toString(), value = settings.playbackProxyServerPort.toString(),
dialogDescription = stringResource(Res.string.settings_playback_port_description), dialogDescription = stringResource(Res.string.settings_playback_port_description),
@ -155,6 +158,8 @@ internal fun LazyListScope.playbackSection(
} }
} }
) )
} },
)
)
} }

View File

@ -17,327 +17,38 @@
package dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Activity
import compose.icons.feathericons.AlignLeft
import compose.icons.feathericons.Check
import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.ChevronRight
import compose.icons.feathericons.ExternalLink
import compose.icons.feathericons.FileText
import compose.icons.feathericons.Music
import compose.icons.feathericons.Package import compose.icons.feathericons.Package
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginManagerStates
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import kotlinx.coroutines.flow.StateFlow import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@Composable
fun DefaultAbilityPluginSelector(
ability: PluginAbility,
state: StateFlow<List<PluginEntry>>,
selectedPlugin: PluginEntry? = null,
onSelected: (PluginEntry?) -> Unit = { },
onManagePlugins: () -> Unit = { }
) {
val plugins by state.collectAsStateWithLifecycle()
val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins)
val clearText = stringResource(Res.string.settings_plugins_clear)
val manageText = stringResource(Res.string.settings_plugins_manage_title)
val menuItems = buildList {
if (plugins.isNotEmpty()) {
plugins.forEach { plugin ->
val isSelected = selectedPlugin?.name == plugin.name
add(
AdaptiveMenuItem(
label = plugin.name,
onClick = { onSelected(plugin) },
selected = isSelected,
)
)
}
if (selectedPlugin != null) {
add(
AdaptiveMenuItem(
icon = FeatherIcons.AlignLeft,
label = clearText,
onClick = { onSelected(null) },
dividerBefore = true,
)
)
}
} else {
add(
AdaptiveMenuItem(
label = noPluginsText,
onClick = { },
enabled = false,
)
)
}
add(
AdaptiveMenuItem(
icon = FeatherIcons.ExternalLink,
label = manageText,
onClick = onManagePlugins,
dividerBefore = true,
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
border = BorderStroke(
width = 1.dp,
color = if (selectedPlugin != null) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.outlineVariant
}
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
color = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
}
) {
Icon(
imageVector = when (ability) {
PluginAbility.METADATA -> FeatherIcons.FileText
PluginAbility.AUDIO -> FeatherIcons.Music
PluginAbility.LYRICS -> FeatherIcons.AlignLeft
PluginAbility.SCROBBLE -> FeatherIcons.Activity
},
contentDescription = stringResource(
Res.string.settings_plugins_plugin_content_description,
ability.displayLabel()
),
modifier = Modifier.padding(8.dp),
tint = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50)
PluginAbility.AUDIO -> Color(0xFF2196F3)
PluginAbility.LYRICS -> Color(0xFFFFC107)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
}
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(
Res.string.settings_plugins_default_ability_title,
ability.displayLabel()
),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (selectedPlugin != null) {
Text(
selectedPlugin.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
} else {
Text(
stringResource(Res.string.settings_plugins_no_selection),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
AdaptiveDropdownBottomSheet(
items = menuItems,
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
header = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
color = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
}
) {
Icon(
imageVector = when (ability) {
PluginAbility.METADATA -> FeatherIcons.FileText
PluginAbility.AUDIO -> FeatherIcons.Music
PluginAbility.LYRICS -> FeatherIcons.AlignLeft
PluginAbility.SCROBBLE -> FeatherIcons.Activity
},
contentDescription = null,
modifier = Modifier.padding(8.dp),
tint = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50)
PluginAbility.AUDIO -> Color(0xFF2196F3)
PluginAbility.LYRICS -> Color(0xFFFFC107)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
}
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(
Res.string.settings_plugins_default_ability_title,
ability.displayLabel()
),
style = MaterialTheme.typography.titleMedium,
)
selectedPlugin?.let {
Text(
it.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
},
trigger = { onClick ->
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
contentColor = MaterialTheme.colorScheme.primary
),
modifier = Modifier.clip(RoundedCornerShape(8.dp))
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = FeatherIcons.Check,
contentDescription = null,
modifier = Modifier.padding(0.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
if (selectedPlugin != null) {
stringResource(Res.string.settings_plugins_action_change)
} else {
stringResource(Res.string.settings_plugins_action_select)
},
style = MaterialTheme.typography.labelSmall
)
}
}
},
)
}
}
}
}
}
internal fun LazyListScope.pluginsSection( internal fun LazyListScope.pluginsSection(
pluginManager: PluginManager,
pluginState: PluginManagerStates,
navigatorCommands: NavigationCommands navigatorCommands: NavigationCommands
) { ) {
item { settingsSectionHeader(Res.string.settings_section_plugins)
Text( settingsSectionCard(
stringResource(Res.string.settings_section_plugins), items = listOf {
style = MaterialTheme.typography.labelMedium,
color = Color.Gray,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
item {
SettingCardItem( SettingCardItem(
title = stringResource(Res.string.settings_plugins_manage_title), title = stringResource(Res.string.settings_plugins_manage_title),
subtitle = stringResource(Res.string.settings_plugins_manage_subtitle), subtitle = stringResource(Res.string.settings_plugins_manage_subtitle),
icon = { icon = {
Surface( SettingsItemIcon(
modifier = Modifier.clip(RoundedCornerShape(8.dp)), Iconsax.IconsaxBoxAdd,
color = Color(0xFFFF9800).copy(alpha = 0.1f) stringResource(Res.string.settings_section_plugins),
) { Color(0xFFFF9800)
Icon(
imageVector = FeatherIcons.Package,
contentDescription = stringResource(Res.string.settings_section_plugins),
modifier = Modifier.padding(8.dp),
tint = Color(0xFFFF9800)
) )
}
}, },
trailingContent = { trailingContent = {
Icon( Icon(
@ -351,35 +62,5 @@ internal fun LazyListScope.pluginsSection(
} }
) )
} }
items(PluginAbility.entries.size) { index ->
val ability = PluginAbility.entries[index]
val selectedPlugin =
(pluginState as? PluginManagerStates.Data)?.selectedPlugins?.get(ability)
DefaultAbilityPluginSelector(
ability = ability,
selectedPlugin = selectedPlugin,
state = when (ability) {
PluginAbility.METADATA -> pluginManager.metadataPlugins
PluginAbility.AUDIO -> pluginManager.audioPlugins
PluginAbility.LYRICS -> pluginManager.lyricsPlugins
PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins
},
onSelected = { plugin ->
pluginManager.setSelectedPlugin(ability, plugin)
},
onManagePlugins = {
navigatorCommands.navigateTo(Routes.Plugins)
}
) )
}
}
@Composable
private fun PluginAbility.displayLabel(): String {
return when (this) {
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
}
} }

View File

@ -18,29 +18,30 @@
package dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import compose.icons.FeatherIcons
import compose.icons.feathericons.RefreshCw
import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshArrow2
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_section_updates import spotube.composeapp.generated.resources.settings_section_updates
import spotube.composeapp.generated.resources.settings_updates_auto_check_subtitle
import spotube.composeapp.generated.resources.settings_updates_auto_check_title
internal fun LazyListScope.updatesSection( internal fun LazyListScope.updatesSection(
settings: UserSettings, settings: UserSettings,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_updates) settingsSectionHeader(Res.string.settings_section_updates)
settingsSectionCard(
item { items = listOf {
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_updates_auto_check_title), title = stringResource(Res.string.settings_updates_auto_check_title),
subtitle = stringResource(Res.string.settings_updates_auto_check_subtitle), subtitle = stringResource(Res.string.settings_updates_auto_check_subtitle),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.RefreshCw, Iconsax.IconsaxRefreshArrow2,
stringResource(Res.string.settings_updates_auto_check_title) stringResource(Res.string.settings_updates_auto_check_title)
) )
}, },
@ -52,5 +53,6 @@ internal fun LazyListScope.updatesSection(
} }
) )
} }
)
} }

View File

@ -18,10 +18,12 @@
package dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -35,6 +37,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import dev.krtirtho.spotube.core.ui.base.Card
private val standardLossyQualities = listOf( private val standardLossyQualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
@ -172,3 +175,30 @@ internal fun normalizePath(path: String): String {
return path.trim().trimEnd('/', '\\') return path.trim().trimEnd('/', '\\')
} }
internal fun LazyListScope.settingsSectionCard(
items: List<@Composable () -> Unit>,
) {
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 4.dp)
) {
items.forEachIndexed { index, content ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
content()
}
}
}
}
}

View File

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

View File

@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -69,6 +68,9 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 // The floating player on small screens that appears above the floating AppBottombar
// //
@ -185,15 +187,17 @@ fun AppFloatingPlayer(
audioPlayerQueue = audioPlayerQueue, audioPlayerQueue = audioPlayerQueue,
savedTracksViewModel = savedTracksViewModel, savedTracksViewModel = savedTracksViewModel,
) )
OutlinedIconButton( Spacer(modifier = Modifier.size(8.dp))
IconButton(
onClick = ::onPlayPause, onClick = ::onPlayPause,
enabled = playerUiState.queue.isNotEmpty() enabled = playerUiState.queue.isNotEmpty(),
theme = LocalBaseUITheme.current.iconButtons.outline.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape)
)
) { ) {
Icon( Icon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", 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.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -40,18 +39,22 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.vector.ImageVector 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 androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes 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.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton 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.IconsaxSidebarLeftBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
import dev.krtirtho.spotube.tabs import dev.krtirtho.spotube.tabs
import org.jetbrains.compose.resources.Font
import org.koin.compose.koinInject import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.cookie_regular
@Composable @Composable
fun AppSidebar( fun AppSidebar(
@ -93,19 +99,26 @@ fun AppSidebar(
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text( Text(
text = "Spotube", 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 color = MaterialTheme.colorScheme.onSurface
) )
} }
GhostIconButton(
onClick = { expanded = !expanded }
) {
Icon( Icon(
imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken, imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken,
contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar", contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(22.dp)
.clickable { expanded = !expanded }
) )
} }
}
Spacer(modifier = Modifier.height(8.dp)) 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 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.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -53,22 +55,28 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import compose.icons.FeatherIcons
import compose.icons.feathericons.MoreVertical
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.rememberLogger 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.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.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.copyShape 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.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.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
@ -188,9 +196,9 @@ fun PlayerQueueContent(
singleLine = true, singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
SecondaryIconButton( IconButton(
onClick = viewModel::clearQueue, 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") Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
} }
@ -203,19 +211,17 @@ fun PlayerQueueContent(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
Card {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = lazyListState, state = lazyListState,
contentPadding = PaddingValues(bottom = 8.dp), contentPadding = PaddingValues(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
items(displayList, key = { item -> item.id }) { item -> items(displayList, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
QueueItemRow( QueueItemRow(
item = item, item = item,
isDragging = isDragging,
elevation = elevation,
reorderScope = if (isFiltered) null else this, reorderScope = if (isFiltered) null else this,
onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
@ -227,13 +233,12 @@ fun PlayerQueueContent(
} }
} }
} }
}
} }
@Composable @Composable
private fun QueueItemRow( private fun QueueItemRow(
item: QueueItemUi, item: QueueItemUi,
isDragging: Boolean,
elevation: androidx.compose.ui.unit.Dp,
reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?, reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onRemoveClick: () -> Unit, onRemoveClick: () -> Unit,
@ -241,30 +246,18 @@ private fun QueueItemRow(
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
val rowColor = if (item.isCurrent) { ListRowTile(
MaterialTheme.colorScheme.secondaryContainer onClick = onPlayClick,
} else { selected = item.isCurrent,
MaterialTheme.colorScheme.surfaceContainerHigh modifier = Modifier,
} leading = {
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable(onClick = onPlayClick),
shadowElevation = elevation,
tonalElevation = if (item.isCurrent) 2.dp else 0.dp,
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .height(72.dp),
.height(72.dp)
.background(rowColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
FeatherIcons.MoreVertical, Iconsax.IconsaxDragHandle,
contentDescription = if (reorderScope != null) "Reorder" else null, contentDescription = if (reorderScope != null) "Reorder" else null,
modifier = Modifier modifier = Modifier
.size(24.dp) .size(24.dp)
@ -300,10 +293,9 @@ private fun QueueItemRow(
) )
} }
} }
}
Spacer(modifier = Modifier.width(12.dp)) },
title = {
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = item.title, text = item.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@ -315,6 +307,8 @@ private fun QueueItemRow(
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
}, },
) )
},
subtitle = {
Text( Text(
text = item.subtitle, text = item.subtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -322,10 +316,8 @@ private fun QueueItemRow(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} },
trailing = {
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = item.durationMs.toDurationString(), text = item.durationMs.toDurationString(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -340,7 +332,7 @@ private fun QueueItemRow(
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
) { ) {
Icon( Icon(
FeatherIcons.MoreVertical, Iconsax.Iconsax3DotsMore,
contentDescription = "More options", contentDescription = "More options",
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
@ -362,7 +354,7 @@ private fun QueueItemRow(
} }
} }
} }
} )
} }
private fun QueueEntry.toQueueDisplayData(): Tuple4<String, String, Long, String?> { private fun QueueEntry.toQueueDisplayData(): Tuple4<String, String, Long, String?> {

View File

@ -0,0 +1,150 @@
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.CustomServer: ImageVector
get() {
if (_CustomServer != null) {
return _CustomServer!!
}
_CustomServer = ImageVector.Builder(
name = "CustomServer",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
// Layer 1: The solid, bright Status Check Badge (bottom right)
path(fill = SolidColor(Color.White)) {
moveTo(18.5f, 11f)
curveTo(14.91f, 11f, 12f, 13.91f, 12f, 17.5f)
curveTo(12f, 21.09f, 14.91f, 24f, 18.5f, 24f)
curveTo(22.09f, 24f, 25f, 21.09f, 25f, 17.5f)
curveTo(25f, 13.91f, 22.09f, 11f, 18.5f, 11f)
close()
// The cutout shape of the checkmark
moveTo(17.2f, 20.3f)
lineTo(14.7f, 17.8f)
curveTo(14.31f, 17.41f, 14.31f, 16.78f, 14.7f, 16.39f)
curveTo(15.09f, 16f, 15.72f, 16f, 16.11f, 16.39f)
lineTo(17.9f, 18.18f)
lineTo(21.39f, 14.69f)
curveTo(21.78f, 14.3f, 22.41f, 14.3f, 22.8f, 14.69f)
curveTo(23.19f, 15.08f, 23.19f, 15.71f, 22.8f, 16.1f)
lineTo(18.6f, 20.3f)
curveTo(18.21f, 20.69f, 17.59f, 20.69f, 17.2f, 20.3f)
close()
}
// Layer 2: Main Server Rack Chassis (Top & Bottom shells with 40% opacity)
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
// Outer border of the double-slot server rack structure
moveTo(20.5f, 8.5f)
verticalLineTo(7f)
curveTo(20.5f, 3.5f, 18f, 1.5f, 13f, 1.5f)
horizontalLineTo(7f)
curveTo(3f, 1.5f, 1.5f, 3.5f, 1.5f, 7f)
verticalLineTo(9.5f)
curveTo(1.5f, 11.5f, 3f, 12f, 4.5f, 12f)
curveTo(3f, 12f, 1.5f, 12.5f, 1.5f, 14.5f)
verticalLineTo(17f)
curveTo(1.5f, 20.5f, 3f, 22.5f, 7f, 22.5f)
horizontalLineTo(10f)
curveTo(10.55f, 22.5f, 11f, 22.05f, 11f, 21.5f)
curveTo(11f, 20.95f, 10.55f, 20.5f, 10f, 20.5f)
horizontalLineTo(7f)
curveTo(4.5f, 20.5f, 3.5f, 19.5f, 3.5f, 17f)
verticalLineTo(14.5f)
curveTo(3.5f, 13f, 4.5f, 13f, 6f, 13f)
curveTo(6.55f, 13f, 7f, 12.55f, 7f, 12f)
curveTo(7f, 11.45f, 6.55f, 11f, 6f, 11f)
curveTo(4.5f, 11f, 3.5f, 11f, 3.5f, 9.5f)
verticalLineTo(7f)
curveTo(3.5f, 4.5f, 4.5f, 3.5f, 7f, 3.5f)
horizontalLineTo(13f)
curveTo(16.5f, 3.5f, 18.5f, 4.5f, 18.5f, 7f)
verticalLineTo(8.5f)
curveTo(18.5f, 9.05f, 18.95f, 9.5f, 19.5f, 9.5f)
curveTo(20.05f, 9.5f, 20.5f, 9.05f, 20.5f, 8.5f)
close()
}
// Layer 3: Server Front Panel Slots, Buttons, and LEDs (100% white)
path(fill = SolidColor(Color.White)) {
// Top Slot LED 1
moveTo(5.5f, 5.5f)
curveTo(6.05f, 5.5f, 6.5f, 5.95f, 6.5f, 6.5f)
verticalLineTo(7.5f)
curveTo(6.5f, 8.05f, 6.05f, 8.5f, 5.5f, 8.5f)
curveTo(4.95f, 8.5f, 4.5f, 8.05f, 4.5f, 7.5f)
verticalLineTo(6.5f)
curveTo(4.5f, 5.95f, 4.95f, 5.5f, 5.5f, 5.5f)
close()
// Top Slot LED 2
moveTo(8.5f, 5.5f)
curveTo(9.05f, 5.5f, 9.5f, 5.95f, 9.5f, 6.5f)
verticalLineTo(7.5f)
curveTo(9.5f, 8.05f, 9.05f, 8.5f, 8.5f, 8.5f)
curveTo(7.95f, 8.5f, 7.5f, 8.05f, 7.5f, 7.5f)
verticalLineTo(6.5f)
curveTo(7.5f, 5.95f, 7.95f, 5.5f, 8.5f, 5.5f)
close()
// Top Slot Button Strip
moveTo(11.5f, 6f)
horizontalLineTo(16.5f)
curveTo(17.05f, 6f, 17.5f, 6.45f, 17.5f, 7f)
curveTo(17.5f, 7.55f, 17.05f, 8f, 16.5f, 8f)
horizontalLineTo(11.5f)
curveTo(10.95f, 8f, 10.5f, 7.55f, 10.5f, 7f)
curveTo(10.5f, 6.45f, 10.95f, 6f, 11.5f, 6f)
close()
// Bottom Slot LED 1
moveTo(5.5f, 14.5f)
curveTo(6.05f, 14.5f, 6.5f, 14.95f, 6.5f, 15.5f)
verticalLineTo(16.5f)
curveTo(6.5f, 17.05f, 6.05f, 17.5f, 5.5f, 17.5f)
curveTo(4.95f, 17.5f, 4.5f, 17.05f, 4.5f, 16.5f)
verticalLineTo(15.5f)
curveTo(4.5f, 14.95f, 4.95f, 14.5f, 5.5f, 14.5f)
close()
// Bottom Slot LED 2
moveTo(8.5f, 14.5f)
curveTo(9.05f, 14.5f, 9.5f, 14.95f, 9.5f, 15.5f)
verticalLineTo(16.5f)
curveTo(9.5f, 17.05f, 9.05f, 17.5f, 8.5f, 17.5f)
curveTo(7.95f, 17.5f, 7.5f, 17.05f, 7.5f, 16.5f)
verticalLineTo(15.5f)
curveTo(7.5f, 14.95f, 7.95f, 14.5f, 8.5f, 14.5f)
close()
}
}
}.build()
return _CustomServer!!
}
@Suppress("ObjectPropertyName")
private var _CustomServer: ImageVector? = null

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 package dev.krtirtho.spotube.resources.iconsax
object Iconsax object Iconsax

View File

@ -0,0 +1,120 @@
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.IconsaxBox: ImageVector
get() {
if (_IconsaxBox != null) {
return _IconsaxBox!!
}
_IconsaxBox = ImageVector.Builder(
name = "IconsaxBox",
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(20.1f, 6.94f)
curveTo(20.1f, 7.48f, 19.81f, 7.97f, 19.35f, 8.22f)
lineTo(17.61f, 9.16f)
lineTo(16.13f, 9.95f)
lineTo(13.06f, 11.61f)
curveTo(12.73f, 11.79f, 12.37f, 11.88f, 12f, 11.88f)
curveTo(11.63f, 11.88f, 11.27f, 11.79f, 10.94f, 11.61f)
lineTo(4.65f, 8.22f)
curveTo(4.19f, 7.97f, 3.9f, 7.48f, 3.9f, 6.94f)
curveTo(3.9f, 6.4f, 4.19f, 5.91f, 4.65f, 5.66f)
lineTo(6.62f, 4.6f)
lineTo(8.19f, 3.75f)
lineTo(10.94f, 2.27f)
curveTo(11.6f, 1.91f, 12.4f, 1.91f, 13.06f, 2.27f)
lineTo(19.35f, 5.66f)
curveTo(19.81f, 5.91f, 20.1f, 6.4f, 20.1f, 6.94f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(9.9f, 12.79f)
lineTo(4.05f, 9.86f)
curveTo(3.6f, 9.63f, 3.08f, 9.66f, 2.65f, 9.92f)
curveTo(2.22f, 10.18f, 1.97f, 10.64f, 1.97f, 11.14f)
verticalLineTo(16.67f)
curveTo(1.97f, 17.63f, 2.5f, 18.49f, 3.36f, 18.92f)
lineTo(9.21f, 21.84f)
curveTo(9.41f, 21.94f, 9.63f, 21.99f, 9.85f, 21.99f)
curveTo(10.11f, 21.99f, 10.37f, 21.92f, 10.6f, 21.77f)
curveTo(11.03f, 21.51f, 11.28f, 21.05f, 11.28f, 20.55f)
verticalLineTo(15.02f)
curveTo(11.29f, 14.08f, 10.76f, 13.22f, 9.9f, 12.79f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(22.03f, 11.15f)
verticalLineTo(16.68f)
curveTo(22.03f, 17.63f, 21.5f, 18.49f, 20.64f, 18.92f)
lineTo(14.79f, 21.85f)
curveTo(14.59f, 21.95f, 14.37f, 22f, 14.15f, 22f)
curveTo(13.89f, 22f, 13.63f, 21.93f, 13.39f, 21.78f)
curveTo(12.97f, 21.52f, 12.71f, 21.06f, 12.71f, 20.56f)
verticalLineTo(15.04f)
curveTo(12.71f, 14.08f, 13.24f, 13.22f, 14.1f, 12.79f)
lineTo(16.25f, 11.72f)
lineTo(17.75f, 10.97f)
lineTo(19.95f, 9.87f)
curveTo(20.4f, 9.64f, 20.92f, 9.66f, 21.35f, 9.93f)
curveTo(21.77f, 10.19f, 22.03f, 10.65f, 22.03f, 11.15f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(17.61f, 9.16f)
lineTo(16.13f, 9.95f)
lineTo(6.62f, 4.6f)
lineTo(8.19f, 3.75f)
lineTo(17.37f, 8.93f)
curveTo(17.47f, 8.99f, 17.55f, 9.07f, 17.61f, 9.16f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(17.75f, 10.971f)
verticalLineTo(13.241f)
curveTo(17.75f, 13.651f, 17.41f, 13.991f, 17f, 13.991f)
curveTo(16.59f, 13.991f, 16.25f, 13.651f, 16.25f, 13.241f)
verticalLineTo(11.721f)
lineTo(17.75f, 10.971f)
close()
}
}
}.build()
return _IconsaxBox!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxBox: ImageVector? = null

View File

@ -0,0 +1,109 @@
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.IconsaxColorSwatch: ImageVector
get() {
if (_IconsaxColorSwatch != null) {
return _IconsaxColorSwatch!!
}
_IconsaxColorSwatch = ImageVector.Builder(
name = "IconsaxColorSwatch",
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(22f, 16.5f)
verticalLineTo(19.5f)
curveTo(22f, 21f, 21f, 22f, 19.5f, 22f)
horizontalLineTo(6f)
curveTo(6.41f, 22f, 6.83f, 21.94f, 7.22f, 21.81f)
curveTo(7.33f, 21.77f, 7.44f, 21.73f, 7.55f, 21.68f)
curveTo(7.9f, 21.54f, 8.24f, 21.34f, 8.54f, 21.08f)
curveTo(8.63f, 21.01f, 8.73f, 20.92f, 8.82f, 20.83f)
lineTo(8.86f, 20.79f)
lineTo(15.66f, 14f)
horizontalLineTo(19.5f)
curveTo(21f, 14f, 22f, 15f, 22f, 16.5f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.6f,
strokeAlpha = 0.6f
) {
moveTo(18.37f, 11.291f)
lineTo(15.66f, 14.001f)
lineTo(8.86f, 20.791f)
curveTo(9.56f, 20.071f, 10f, 19.081f, 10f, 18.001f)
verticalLineTo(8.341f)
lineTo(12.71f, 5.631f)
curveTo(13.77f, 4.571f, 15.19f, 4.571f, 16.25f, 5.631f)
lineTo(18.37f, 7.751f)
curveTo(19.43f, 8.811f, 19.43f, 10.231f, 18.37f, 11.291f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(7.5f, 2f)
horizontalLineTo(4.5f)
curveTo(3f, 2f, 2f, 3f, 2f, 4.5f)
verticalLineTo(18f)
curveTo(2f, 18.27f, 2.03f, 18.54f, 2.08f, 18.8f)
curveTo(2.11f, 18.93f, 2.14f, 19.06f, 2.18f, 19.19f)
curveTo(2.23f, 19.34f, 2.28f, 19.49f, 2.34f, 19.63f)
curveTo(2.35f, 19.64f, 2.35f, 19.65f, 2.35f, 19.65f)
curveTo(2.36f, 19.65f, 2.36f, 19.65f, 2.35f, 19.66f)
curveTo(2.49f, 19.94f, 2.65f, 20.21f, 2.84f, 20.46f)
curveTo(2.95f, 20.59f, 3.06f, 20.71f, 3.17f, 20.83f)
curveTo(3.28f, 20.95f, 3.4f, 21.05f, 3.53f, 21.15f)
lineTo(3.54f, 21.16f)
curveTo(3.79f, 21.35f, 4.06f, 21.51f, 4.34f, 21.65f)
curveTo(4.35f, 21.64f, 4.35f, 21.64f, 4.35f, 21.65f)
curveTo(4.5f, 21.72f, 4.65f, 21.77f, 4.81f, 21.82f)
curveTo(4.94f, 21.86f, 5.07f, 21.89f, 5.2f, 21.92f)
curveTo(5.46f, 21.97f, 5.73f, 22f, 6f, 22f)
curveTo(6.41f, 22f, 6.83f, 21.94f, 7.22f, 21.81f)
curveTo(7.33f, 21.77f, 7.44f, 21.73f, 7.55f, 21.68f)
curveTo(7.9f, 21.54f, 8.24f, 21.34f, 8.54f, 21.08f)
curveTo(8.63f, 21.01f, 8.73f, 20.92f, 8.82f, 20.83f)
lineTo(8.86f, 20.79f)
curveTo(9.56f, 20.07f, 10f, 19.08f, 10f, 18f)
verticalLineTo(4.5f)
curveTo(10f, 3f, 9f, 2f, 7.5f, 2f)
close()
moveTo(6f, 19.5f)
curveTo(5.17f, 19.5f, 4.5f, 18.83f, 4.5f, 18f)
curveTo(4.5f, 17.17f, 5.17f, 16.5f, 6f, 16.5f)
curveTo(6.83f, 16.5f, 7.5f, 17.17f, 7.5f, 18f)
curveTo(7.5f, 18.83f, 6.83f, 19.5f, 6f, 19.5f)
close()
}
}
}.build()
return _IconsaxColorSwatch!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxColorSwatch: ImageVector? = null

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 package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color

View File

@ -0,0 +1,90 @@
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.IconsaxDocumentDownload: ImageVector
get() {
if (_IconsaxDocumentDownload != null) {
return _IconsaxDocumentDownload!!
}
_IconsaxDocumentDownload = ImageVector.Builder(
name = "IconsaxDocumentDownload",
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(20.5f, 10.19f)
horizontalLineTo(17.61f)
curveTo(15.24f, 10.19f, 13.31f, 8.26f, 13.31f, 5.89f)
verticalLineTo(3f)
curveTo(13.31f, 2.45f, 12.86f, 2f, 12.31f, 2f)
horizontalLineTo(8.07f)
curveTo(4.99f, 2f, 2.5f, 4f, 2.5f, 7.57f)
verticalLineTo(16.43f)
curveTo(2.5f, 20f, 4.99f, 22f, 8.07f, 22f)
horizontalLineTo(15.93f)
curveTo(19.01f, 22f, 21.5f, 20f, 21.5f, 16.43f)
verticalLineTo(11.19f)
curveTo(21.5f, 10.64f, 21.05f, 10.19f, 20.5f, 10.19f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(15.8f, 2.21f)
curveTo(15.39f, 1.8f, 14.68f, 2.08f, 14.68f, 2.65f)
verticalLineTo(6.14f)
curveTo(14.68f, 7.6f, 15.92f, 8.81f, 17.43f, 8.81f)
curveTo(18.38f, 8.82f, 19.7f, 8.82f, 20.83f, 8.82f)
curveTo(21.4f, 8.82f, 21.7f, 8.15f, 21.3f, 7.75f)
curveTo(19.86f, 6.3f, 17.28f, 3.69f, 15.8f, 2.21f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12.28f, 14.72f)
curveTo(11.99f, 14.43f, 11.51f, 14.43f, 11.22f, 14.72f)
lineTo(10.5f, 15.44f)
verticalLineTo(11.25f)
curveTo(10.5f, 10.84f, 10.16f, 10.5f, 9.75f, 10.5f)
curveTo(9.34f, 10.5f, 9f, 10.84f, 9f, 11.25f)
verticalLineTo(15.44f)
lineTo(8.28f, 14.72f)
curveTo(7.99f, 14.43f, 7.51f, 14.43f, 7.22f, 14.72f)
curveTo(6.93f, 15.01f, 6.93f, 15.49f, 7.22f, 15.78f)
lineTo(9.22f, 17.78f)
curveTo(9.23f, 17.79f, 9.24f, 17.79f, 9.24f, 17.8f)
curveTo(9.3f, 17.86f, 9.38f, 17.91f, 9.46f, 17.95f)
curveTo(9.56f, 17.98f, 9.65f, 18f, 9.75f, 18f)
curveTo(9.85f, 18f, 9.94f, 17.98f, 10.03f, 17.94f)
curveTo(10.12f, 17.9f, 10.2f, 17.85f, 10.28f, 17.78f)
lineTo(12.28f, 15.78f)
curveTo(12.57f, 15.49f, 12.57f, 15.01f, 12.28f, 14.72f)
close()
}
}
}.build()
return _IconsaxDocumentDownload!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxDocumentDownload: ImageVector? = null

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

@ -0,0 +1,61 @@
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.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxExportArrowBulk: ImageVector
get() {
if (_IconsaxExportArrowBulk != null) {
return _IconsaxExportArrowBulk!!
}
_IconsaxExportArrowBulk = ImageVector.Builder(
name = "IconsaxExportArrowBulk",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(16.8f, 9f)
horizontalLineTo(7.2f)
curveTo(4f, 9f, 2f, 11f, 2f, 14.2f)
verticalLineTo(16.79f)
curveTo(2f, 20f, 4f, 22f, 7.2f, 22f)
horizontalLineTo(16.79f)
curveTo(19.99f, 22f, 21.99f, 20f, 21.99f, 16.8f)
verticalLineTo(14.2f)
curveTo(22f, 11f, 20f, 9f, 16.8f, 9f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(15.88f, 5.57f)
lineTo(12.53f, 2.22f)
curveTo(12.24f, 1.93f, 11.76f, 1.93f, 11.47f, 2.22f)
lineTo(8.12f, 5.57f)
curveTo(7.83f, 5.86f, 7.83f, 6.34f, 8.12f, 6.63f)
curveTo(8.41f, 6.92f, 8.89f, 6.92f, 9.18f, 6.63f)
lineTo(11.25f, 4.56f)
verticalLineTo(15.25f)
curveTo(11.25f, 15.66f, 11.59f, 16f, 12f, 16f)
curveTo(12.41f, 16f, 12.75f, 15.66f, 12.75f, 15.25f)
verticalLineTo(4.56f)
lineTo(14.82f, 6.63f)
curveTo(14.97f, 6.78f, 15.16f, 6.85f, 15.35f, 6.85f)
curveTo(15.54f, 6.85f, 15.73f, 6.78f, 15.88f, 6.63f)
curveTo(16.18f, 6.34f, 16.18f, 5.87f, 15.88f, 5.57f)
close()
}
}.build()
return _IconsaxExportArrowBulk!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxExportArrowBulk: ImageVector? = null

View File

@ -0,0 +1,69 @@
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.IconsaxEye: ImageVector
get() {
if (_IconsaxEye != null) {
return _IconsaxEye!!
}
_IconsaxEye = ImageVector.Builder(
name = "IconsaxEye",
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(21.25f, 9.15f)
curveTo(18.94f, 5.52f, 15.56f, 3.43f, 12f, 3.43f)
curveTo(10.22f, 3.43f, 8.49f, 3.95f, 6.91f, 4.92f)
curveTo(5.33f, 5.9f, 3.91f, 7.33f, 2.75f, 9.15f)
curveTo(1.75f, 10.72f, 1.75f, 13.27f, 2.75f, 14.84f)
curveTo(5.06f, 18.48f, 8.44f, 20.56f, 12f, 20.56f)
curveTo(13.78f, 20.56f, 15.51f, 20.04f, 17.09f, 19.07f)
curveTo(18.67f, 18.09f, 20.09f, 16.66f, 21.25f, 14.84f)
curveTo(22.25f, 13.28f, 22.25f, 10.72f, 21.25f, 9.15f)
close()
moveTo(12f, 16.04f)
curveTo(9.76f, 16.04f, 7.96f, 14.23f, 7.96f, 12f)
curveTo(7.96f, 9.77f, 9.76f, 7.96f, 12f, 7.96f)
curveTo(14.24f, 7.96f, 16.04f, 9.77f, 16.04f, 12f)
curveTo(16.04f, 14.23f, 14.24f, 16.04f, 12f, 16.04f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 9.141f)
curveTo(10.43f, 9.141f, 9.15f, 10.421f, 9.15f, 12.001f)
curveTo(9.15f, 13.571f, 10.43f, 14.851f, 12f, 14.851f)
curveTo(13.57f, 14.851f, 14.86f, 13.571f, 14.86f, 12.001f)
curveTo(14.86f, 10.431f, 13.57f, 9.141f, 12f, 9.141f)
close()
}
}
}.build()
return _IconsaxEye!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxEye: ImageVector? = null

View File

@ -0,0 +1,68 @@
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.IconsaxFolder: ImageVector
get() {
if (_IconsaxFolder != null) {
return _IconsaxFolder!!
}
_IconsaxFolder = ImageVector.Builder(
name = "IconsaxFolder",
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)) {
moveTo(22f, 11.07f)
verticalLineTo(16.65f)
curveTo(22f, 19.6f, 19.6f, 22f, 16.65f, 22f)
horizontalLineTo(7.35f)
curveTo(4.4f, 22f, 2f, 19.6f, 2f, 16.65f)
verticalLineTo(9.44f)
horizontalLineTo(21.74f)
curveTo(21.89f, 9.89f, 21.97f, 10.35f, 21.99f, 10.84f)
curveTo(22f, 10.91f, 22f, 11f, 22f, 11.07f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(21.74f, 9.44f)
horizontalLineTo(2f)
verticalLineTo(6.42f)
curveTo(2f, 3.98f, 3.98f, 2f, 6.42f, 2f)
horizontalLineTo(8.75f)
curveTo(10.38f, 2f, 10.89f, 2.53f, 11.54f, 3.4f)
lineTo(12.94f, 5.26f)
curveTo(13.25f, 5.67f, 13.29f, 5.73f, 13.87f, 5.73f)
horizontalLineTo(16.66f)
curveTo(19.03f, 5.72f, 21.05f, 7.28f, 21.74f, 9.44f)
close()
}
}
}.build()
return _IconsaxFolder!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxFolder: ImageVector? = null

View File

@ -0,0 +1,86 @@
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.IconsaxFolderAdd: ImageVector
get() {
if (_IconsaxFolderAdd != null) {
return _IconsaxFolderAdd!!
}
_IconsaxFolderAdd = ImageVector.Builder(
name = "IconsaxFolderAdd",
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(21.74f, 9.44f)
horizontalLineTo(2f)
verticalLineTo(6.42f)
curveTo(2f, 3.98f, 3.98f, 2f, 6.42f, 2f)
horizontalLineTo(8.75f)
curveTo(10.38f, 2f, 10.89f, 2.53f, 11.54f, 3.4f)
lineTo(12.94f, 5.26f)
curveTo(13.25f, 5.67f, 13.29f, 5.73f, 13.87f, 5.73f)
horizontalLineTo(16.66f)
curveTo(19.03f, 5.72f, 21.05f, 7.28f, 21.74f, 9.44f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(21.99f, 10.84f)
curveTo(21.97f, 10.35f, 21.89f, 9.89f, 21.74f, 9.44f)
horizontalLineTo(2f)
verticalLineTo(16.65f)
curveTo(2f, 19.6f, 4.4f, 22f, 7.35f, 22f)
horizontalLineTo(16.65f)
curveTo(19.6f, 22f, 22f, 19.6f, 22f, 16.65f)
verticalLineTo(11.07f)
curveTo(22f, 11f, 22f, 10.91f, 21.99f, 10.84f)
close()
moveTo(14.5f, 16.25f)
horizontalLineTo(12.81f)
verticalLineTo(18f)
curveTo(12.81f, 18.41f, 12.47f, 18.75f, 12.06f, 18.75f)
curveTo(11.65f, 18.75f, 11.31f, 18.41f, 11.31f, 18f)
verticalLineTo(16.25f)
horizontalLineTo(9.5f)
curveTo(9.09f, 16.25f, 8.75f, 15.91f, 8.75f, 15.5f)
curveTo(8.75f, 15.09f, 9.09f, 14.75f, 9.5f, 14.75f)
horizontalLineTo(11.31f)
verticalLineTo(13f)
curveTo(11.31f, 12.59f, 11.65f, 12.25f, 12.06f, 12.25f)
curveTo(12.47f, 12.25f, 12.81f, 12.59f, 12.81f, 13f)
verticalLineTo(14.75f)
horizontalLineTo(14.5f)
curveTo(14.91f, 14.75f, 15.25f, 15.09f, 15.25f, 15.5f)
curveTo(15.25f, 15.91f, 14.91f, 16.25f, 14.5f, 16.25f)
close()
}
}
}.build()
return _IconsaxFolderAdd!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxFolderAdd: ImageVector? = null

View File

@ -0,0 +1,140 @@
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.IconsaxGlobe: ImageVector
get() {
if (_IconsaxGlobe != null) {
return _IconsaxGlobe!!
}
_IconsaxGlobe = ImageVector.Builder(
name = "IconsaxGlobe",
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(7.65f, 20.91f)
curveTo(7.62f, 20.91f, 7.58f, 20.93f, 7.55f, 20.93f)
curveTo(5.61f, 19.97f, 4.03f, 18.38f, 3.06f, 16.44f)
curveTo(3.06f, 16.41f, 3.08f, 16.37f, 3.08f, 16.34f)
curveTo(4.3f, 16.7f, 5.56f, 16.97f, 6.81f, 17.18f)
curveTo(7.03f, 18.44f, 7.29f, 19.69f, 7.65f, 20.91f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(20.94f, 16.45f)
curveTo(19.95f, 18.44f, 18.3f, 20.05f, 16.29f, 21.02f)
curveTo(16.67f, 19.75f, 16.99f, 18.47f, 17.2f, 17.18f)
curveTo(18.46f, 16.97f, 19.7f, 16.7f, 20.92f, 16.34f)
curveTo(20.91f, 16.38f, 20.94f, 16.42f, 20.94f, 16.45f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(21.02f, 7.71f)
curveTo(19.76f, 7.33f, 18.49f, 7.02f, 17.2f, 6.8f)
curveTo(16.99f, 5.51f, 16.68f, 4.23f, 16.29f, 2.98f)
curveTo(18.36f, 3.97f, 20.03f, 5.64f, 21.02f, 7.71f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(7.65f, 3.091f)
curveTo(7.29f, 4.311f, 7.03f, 5.551f, 6.82f, 6.811f)
curveTo(5.53f, 7.011f, 4.25f, 7.331f, 2.98f, 7.711f)
curveTo(3.95f, 5.701f, 5.56f, 4.051f, 7.55f, 3.061f)
curveTo(7.58f, 3.061f, 7.62f, 3.091f, 7.65f, 3.091f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(15.49f, 6.59f)
curveTo(13.17f, 6.33f, 10.83f, 6.33f, 8.51f, 6.59f)
curveTo(8.76f, 5.22f, 9.08f, 3.85f, 9.53f, 2.53f)
curveTo(9.55f, 2.45f, 9.54f, 2.39f, 9.55f, 2.31f)
curveTo(10.34f, 2.12f, 11.15f, 2f, 12f, 2f)
curveTo(12.84f, 2f, 13.66f, 2.12f, 14.44f, 2.31f)
curveTo(14.45f, 2.39f, 14.45f, 2.45f, 14.47f, 2.53f)
curveTo(14.92f, 3.86f, 15.24f, 5.22f, 15.49f, 6.59f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(6.59f, 15.49f)
curveTo(5.21f, 15.24f, 3.85f, 14.92f, 2.53f, 14.47f)
curveTo(2.45f, 14.45f, 2.39f, 14.46f, 2.31f, 14.45f)
curveTo(2.12f, 13.66f, 2f, 12.85f, 2f, 12f)
curveTo(2f, 11.16f, 2.12f, 10.34f, 2.31f, 9.56f)
curveTo(2.39f, 9.55f, 2.45f, 9.55f, 2.53f, 9.53f)
curveTo(3.86f, 9.09f, 5.21f, 8.76f, 6.59f, 8.51f)
curveTo(6.34f, 10.83f, 6.34f, 13.17f, 6.59f, 15.49f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(22f, 12f)
curveTo(22f, 12.85f, 21.88f, 13.66f, 21.69f, 14.45f)
curveTo(21.61f, 14.46f, 21.55f, 14.45f, 21.47f, 14.47f)
curveTo(20.14f, 14.91f, 18.78f, 15.24f, 17.41f, 15.49f)
curveTo(17.67f, 13.17f, 17.67f, 10.83f, 17.41f, 8.51f)
curveTo(18.78f, 8.76f, 20.15f, 9.08f, 21.47f, 9.53f)
curveTo(21.55f, 9.55f, 21.61f, 9.56f, 21.69f, 9.56f)
curveTo(21.88f, 10.35f, 22f, 11.16f, 22f, 12f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(15.49f, 17.41f)
curveTo(15.24f, 18.79f, 14.92f, 20.15f, 14.47f, 21.47f)
curveTo(14.45f, 21.55f, 14.45f, 21.61f, 14.44f, 21.69f)
curveTo(13.66f, 21.88f, 12.84f, 22f, 12f, 22f)
curveTo(11.15f, 22f, 10.34f, 21.88f, 9.55f, 21.69f)
curveTo(9.54f, 21.61f, 9.55f, 21.55f, 9.53f, 21.47f)
curveTo(9.09f, 20.14f, 8.76f, 18.79f, 8.51f, 17.41f)
curveTo(9.67f, 17.54f, 10.83f, 17.63f, 12f, 17.63f)
curveTo(13.17f, 17.63f, 14.34f, 17.54f, 15.49f, 17.41f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(15.763f, 15.763f)
curveTo(13.262f, 16.079f, 10.738f, 16.079f, 8.237f, 15.763f)
curveTo(7.921f, 13.262f, 7.921f, 10.738f, 8.237f, 8.237f)
curveTo(10.738f, 7.921f, 13.262f, 7.921f, 15.763f, 8.237f)
curveTo(16.079f, 10.738f, 16.079f, 13.262f, 15.763f, 15.763f)
close()
}
}
}.build()
return _IconsaxGlobe!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxGlobe: ImageVector? = null

View File

@ -0,0 +1,67 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxImportArrow2: ImageVector
get() {
if (_IconsaxImportArrow2 != null) {
return _IconsaxImportArrow2!!
}
_IconsaxImportArrow2 = ImageVector.Builder(
name = "IconsaxImportArrow2",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(22.001f, 2f)
lineTo(13.801f, 10.2f)
}
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(13f, 6.17f)
verticalLineTo(11f)
horizontalLineTo(17.83f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(11f, 2f)
horizontalLineTo(9f)
curveTo(4f, 2f, 2f, 4f, 2f, 9f)
verticalLineTo(15f)
curveTo(2f, 20f, 4f, 22f, 9f, 22f)
horizontalLineTo(15f)
curveTo(20f, 22f, 22f, 20f, 22f, 15f)
verticalLineTo(13f)
}
}.build()
return _IconsaxImportArrow2!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxImportArrow2: ImageVector? = null

View File

@ -0,0 +1,61 @@
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.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxImportArrow2Bulk: ImageVector
get() {
if (_IconsaxImportArrow2Bulk != null) {
return _IconsaxImportArrow2Bulk!!
}
_IconsaxImportArrow2Bulk = ImageVector.Builder(
name = "IconsaxImportArrow2Bulk",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(16.8f, 9f)
horizontalLineTo(7.2f)
curveTo(4f, 9f, 2f, 11f, 2f, 14.2f)
verticalLineTo(16.79f)
curveTo(2f, 20f, 4f, 22f, 7.2f, 22f)
horizontalLineTo(16.79f)
curveTo(19.99f, 22f, 21.99f, 20f, 21.99f, 16.8f)
verticalLineTo(14.2f)
curveTo(22f, 11f, 20f, 9f, 16.8f, 9f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(15.88f, 12.43f)
lineTo(12.53f, 15.78f)
curveTo(12.24f, 16.07f, 11.76f, 16.07f, 11.47f, 15.78f)
lineTo(8.12f, 12.43f)
curveTo(7.83f, 12.14f, 7.83f, 11.66f, 8.12f, 11.37f)
curveTo(8.41f, 11.08f, 8.89f, 11.08f, 9.18f, 11.37f)
lineTo(11.25f, 13.44f)
verticalLineTo(2.75f)
curveTo(11.25f, 2.34f, 11.59f, 2f, 12f, 2f)
curveTo(12.41f, 2f, 12.75f, 2.34f, 12.75f, 2.75f)
verticalLineTo(13.44f)
lineTo(14.82f, 11.37f)
curveTo(14.97f, 11.22f, 15.16f, 11.15f, 15.35f, 11.15f)
curveTo(15.54f, 11.15f, 15.73f, 11.22f, 15.88f, 11.37f)
curveTo(16.18f, 11.66f, 16.18f, 12.13f, 15.88f, 12.43f)
close()
}
}.build()
return _IconsaxImportArrow2Bulk!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxImportArrow2Bulk: ImageVector? = null

View File

@ -0,0 +1,65 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
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.IconsaxLink: ImageVector
get() {
if (_IconsaxLink != null) {
return _IconsaxLink!!
}
_IconsaxLink = ImageVector.Builder(
name = "IconsaxLink",
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(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(13.06f, 10.94f)
curveTo(15.31f, 13.19f, 15.31f, 16.83f, 13.06f, 19.07f)
curveTo(10.81f, 21.31f, 7.17f, 21.32f, 4.93f, 19.07f)
curveTo(2.69f, 16.82f, 2.68f, 13.18f, 4.93f, 10.94f)
}
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(10.59f, 13.41f)
curveTo(8.25f, 11.07f, 8.25f, 7.27f, 10.59f, 4.92f)
curveTo(12.93f, 2.57f, 16.73f, 2.58f, 19.08f, 4.92f)
curveTo(21.43f, 7.26f, 21.42f, 11.06f, 19.08f, 13.41f)
}
}
}.build()
return _IconsaxLink!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxLink: ImageVector? = null

View File

@ -0,0 +1,90 @@
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.IconsaxMirroringScreen: ImageVector
get() {
if (_IconsaxMirroringScreen != null) {
return _IconsaxMirroringScreen!!
}
_IconsaxMirroringScreen = ImageVector.Builder(
name = "IconsaxMirroringScreen",
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(2f, 9f)
verticalLineTo(8f)
curveTo(2f, 5.24f, 4.24f, 3f, 7f, 3f)
horizontalLineTo(17f)
curveTo(19.76f, 3f, 22f, 5.24f, 22f, 8f)
verticalLineTo(16f)
curveTo(22f, 18.76f, 19.76f, 21f, 17f, 21f)
horizontalLineTo(16f)
horizontalLineTo(7f)
curveTo(4.24f, 21f, 2f, 18.76f, 2f, 16f)
verticalLineTo(9f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(16.14f, 15.439f)
curveTo(15.77f, 15.439f, 15.45f, 15.159f, 15.4f, 14.789f)
curveTo(14.97f, 11.409f, 12.51f, 8.949f, 9.13f, 8.519f)
curveTo(8.72f, 8.469f, 8.43f, 8.089f, 8.48f, 7.679f)
curveTo(8.53f, 7.269f, 8.91f, 6.979f, 9.32f, 7.029f)
curveTo(13.39f, 7.549f, 16.36f, 10.519f, 16.89f, 14.599f)
curveTo(16.94f, 15.009f, 16.65f, 15.389f, 16.24f, 15.439f)
curveTo(16.21f, 15.439f, 16.18f, 15.439f, 16.14f, 15.439f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(13.44f, 16.298f)
curveTo(13.07f, 16.298f, 12.75f, 16.018f, 12.7f, 15.648f)
curveTo(12.39f, 13.268f, 10.66f, 11.528f, 8.27f, 11.218f)
curveTo(7.86f, 11.168f, 7.57f, 10.788f, 7.62f, 10.378f)
curveTo(7.67f, 9.968f, 8.05f, 9.678f, 8.46f, 9.728f)
curveTo(11.54f, 10.128f, 13.79f, 12.368f, 14.19f, 15.458f)
curveTo(14.24f, 15.868f, 13.95f, 16.248f, 13.54f, 16.298f)
curveTo(13.51f, 16.298f, 13.47f, 16.298f, 13.44f, 16.298f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(10.39f, 16.809f)
curveTo(10.02f, 16.809f, 9.7f, 16.529f, 9.65f, 16.159f)
curveTo(9.52f, 15.139f, 8.78f, 14.399f, 7.76f, 14.269f)
curveTo(7.35f, 14.219f, 7.06f, 13.839f, 7.11f, 13.429f)
curveTo(7.16f, 13.019f, 7.54f, 12.729f, 7.95f, 12.779f)
curveTo(9.64f, 12.999f, 10.92f, 14.279f, 11.14f, 15.969f)
curveTo(11.19f, 16.379f, 10.9f, 16.759f, 10.49f, 16.809f)
curveTo(10.46f, 16.809f, 10.42f, 16.809f, 10.39f, 16.809f)
close()
}
}
}.build()
return _IconsaxMirroringScreen!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxMirroringScreen: ImageVector? = null

View File

@ -0,0 +1,109 @@
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.IconsaxMusicPlay: ImageVector
get() {
if (_IconsaxMusicPlay != null) {
return _IconsaxMusicPlay!!
}
_IconsaxMusicPlay = ImageVector.Builder(
name = "IconsaxMusicPlay",
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)) {
moveTo(13.18f, 11.861f)
curveTo(12.78f, 11.861f, 12.42f, 11.641f, 12.25f, 11.281f)
lineTo(10.8f, 8.391f)
lineTo(10.38f, 9.171f)
curveTo(10.15f, 9.601f, 9.69f, 9.871f, 9.2f, 9.871f)
horizontalLineTo(8.47f)
curveTo(8.06f, 9.871f, 7.72f, 9.531f, 7.72f, 9.121f)
curveTo(7.72f, 8.711f, 8.06f, 8.371f, 8.47f, 8.371f)
horizontalLineTo(9.11f)
lineTo(9.9f, 6.911f)
curveTo(10.09f, 6.571f, 10.47f, 6.341f, 10.83f, 6.361f)
curveTo(11.22f, 6.361f, 11.57f, 6.591f, 11.75f, 6.931f)
lineTo(13.18f, 9.791f)
lineTo(13.52f, 9.101f)
curveTo(13.75f, 8.641f, 14.2f, 8.361f, 14.72f, 8.361f)
horizontalLineTo(15.53f)
curveTo(15.94f, 8.361f, 16.28f, 8.701f, 16.28f, 9.111f)
curveTo(16.28f, 9.521f, 15.94f, 9.861f, 15.53f, 9.861f)
horizontalLineTo(14.82f)
lineTo(14.11f, 11.271f)
curveTo(13.93f, 11.641f, 13.58f, 11.861f, 13.18f, 11.861f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(2.75f, 18.651f)
curveTo(2.34f, 18.651f, 2f, 18.311f, 2f, 17.901f)
verticalLineTo(12.201f)
curveTo(1.95f, 9.491f, 2.96f, 6.931f, 4.84f, 5.011f)
curveTo(6.72f, 3.101f, 9.24f, 2.051f, 11.95f, 2.051f)
curveTo(17.49f, 2.051f, 22f, 6.561f, 22f, 12.101f)
verticalLineTo(17.801f)
curveTo(22f, 18.211f, 21.66f, 18.551f, 21.25f, 18.551f)
curveTo(20.84f, 18.551f, 20.5f, 18.211f, 20.5f, 17.801f)
verticalLineTo(12.101f)
curveTo(20.5f, 7.391f, 16.67f, 3.551f, 11.95f, 3.551f)
curveTo(9.64f, 3.551f, 7.5f, 4.441f, 5.91f, 6.061f)
curveTo(4.31f, 7.691f, 3.46f, 9.861f, 3.5f, 12.181f)
verticalLineTo(17.891f)
curveTo(3.5f, 18.311f, 3.17f, 18.651f, 2.75f, 18.651f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(5.94f, 12.451f)
horizontalLineTo(5.81f)
curveTo(3.71f, 12.451f, 2f, 14.161f, 2f, 16.261f)
verticalLineTo(18.141f)
curveTo(2f, 20.241f, 3.71f, 21.951f, 5.81f, 21.951f)
horizontalLineTo(5.94f)
curveTo(8.04f, 21.951f, 9.75f, 20.241f, 9.75f, 18.141f)
verticalLineTo(16.261f)
curveTo(9.75f, 14.161f, 8.04f, 12.451f, 5.94f, 12.451f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(18.19f, 12.451f)
horizontalLineTo(18.06f)
curveTo(15.96f, 12.451f, 14.25f, 14.161f, 14.25f, 16.261f)
verticalLineTo(18.141f)
curveTo(14.25f, 20.241f, 15.96f, 21.951f, 18.06f, 21.951f)
horizontalLineTo(18.19f)
curveTo(20.29f, 21.951f, 22f, 20.241f, 22f, 18.141f)
verticalLineTo(16.261f)
curveTo(22f, 14.161f, 20.29f, 12.451f, 18.19f, 12.451f)
close()
}
}
}.build()
return _IconsaxMusicPlay!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxMusicPlay: ImageVector? = null

View File

@ -0,0 +1,83 @@
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.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxRefreshArrow2: ImageVector
get() {
if (_IconsaxRefreshArrow2 != null) {
return _IconsaxRefreshArrow2!!
}
_IconsaxRefreshArrow2 = ImageVector.Builder(
name = "IconsaxRefreshArrow2",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(22f, 7.81f)
verticalLineTo(16.18f)
curveTo(22f, 19.82f, 19.83f, 21.99f, 16.19f, 21.99f)
horizontalLineTo(7.81f)
curveTo(4.17f, 22f, 2f, 19.83f, 2f, 16.19f)
verticalLineTo(7.81f)
curveTo(2f, 4.17f, 4.17f, 2f, 7.81f, 2f)
horizontalLineTo(16.18f)
curveTo(19.83f, 2f, 22f, 4.17f, 22f, 7.81f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 18.25f)
curveTo(10.21f, 18.25f, 8.81f, 17.36f, 7.86f, 16.48f)
verticalLineTo(17.19f)
curveTo(7.86f, 17.6f, 7.52f, 17.94f, 7.11f, 17.94f)
curveTo(6.7f, 17.94f, 6.36f, 17.6f, 6.36f, 17.19f)
verticalLineTo(14.44f)
curveTo(6.36f, 14.03f, 6.7f, 13.69f, 7.11f, 13.69f)
horizontalLineTo(9.59f)
curveTo(10f, 13.69f, 10.34f, 14.03f, 10.34f, 14.44f)
curveTo(10.34f, 14.85f, 10f, 15.19f, 9.59f, 15.19f)
horizontalLineTo(8.69f)
curveTo(9.43f, 15.93f, 10.58f, 16.75f, 12f, 16.75f)
curveTo(14.62f, 16.75f, 16.75f, 14.62f, 16.75f, 12f)
curveTo(16.75f, 11.59f, 17.09f, 11.25f, 17.5f, 11.25f)
curveTo(17.91f, 11.25f, 18.25f, 11.59f, 18.25f, 12f)
curveTo(18.25f, 15.45f, 15.45f, 18.25f, 12f, 18.25f)
close()
moveTo(6.5f, 12.75f)
curveTo(6.09f, 12.75f, 5.75f, 12.41f, 5.75f, 12f)
curveTo(5.75f, 8.55f, 8.55f, 5.75f, 12f, 5.75f)
curveTo(14.15f, 5.75f, 15.73f, 6.68f, 16.75f, 7.57f)
verticalLineTo(6.81f)
curveTo(16.75f, 6.4f, 17.09f, 6.06f, 17.5f, 6.06f)
curveTo(17.91f, 6.06f, 18.25f, 6.4f, 18.25f, 6.81f)
verticalLineTo(9.55f)
curveTo(18.25f, 9.58f, 18.25f, 9.61f, 18.25f, 9.63f)
curveTo(18.24f, 9.74f, 18.21f, 9.84f, 18.16f, 9.93f)
curveTo(18.11f, 10.02f, 18.04f, 10.1f, 17.95f, 10.17f)
curveTo(17.88f, 10.22f, 17.8f, 10.26f, 17.71f, 10.29f)
curveTo(17.64f, 10.31f, 17.57f, 10.32f, 17.5f, 10.32f)
horizontalLineTo(15.07f)
curveTo(14.66f, 10.32f, 14.32f, 9.98f, 14.32f, 9.57f)
curveTo(14.32f, 9.16f, 14.66f, 8.82f, 15.07f, 8.82f)
horizontalLineTo(15.9f)
curveTo(15.1f, 8.08f, 13.81f, 7.26f, 12.02f, 7.26f)
curveTo(9.4f, 7.26f, 7.27f, 9.39f, 7.27f, 12.01f)
curveTo(7.25f, 12.41f, 6.91f, 12.75f, 6.5f, 12.75f)
close()
}
}.build()
return _IconsaxRefreshArrow2!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxRefreshArrow2: ImageVector? = null

View File

@ -0,0 +1,63 @@
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.IconsaxStopCircle: ImageVector
get() {
if (_IconsaxStopCircle != null) {
return _IconsaxStopCircle!!
}
_IconsaxStopCircle = ImageVector.Builder(
name = "IconsaxStopCircle",
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(11.97f, 22f)
curveTo(17.493f, 22f, 21.97f, 17.523f, 21.97f, 12f)
curveTo(21.97f, 6.477f, 17.493f, 2f, 11.97f, 2f)
curveTo(6.447f, 2f, 1.97f, 6.477f, 1.97f, 12f)
curveTo(1.97f, 17.523f, 6.447f, 22f, 11.97f, 22f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(10.77f, 16.229f)
horizontalLineTo(13.23f)
curveTo(14.89f, 16.229f, 16.23f, 14.889f, 16.23f, 13.229f)
verticalLineTo(10.769f)
curveTo(16.23f, 9.11f, 14.89f, 7.77f, 13.23f, 7.77f)
horizontalLineTo(10.77f)
curveTo(9.11f, 7.77f, 7.77f, 9.11f, 7.77f, 10.769f)
verticalLineTo(13.229f)
curveTo(7.77f, 14.889f, 9.11f, 16.229f, 10.77f, 16.229f)
close()
}
}
}.build()
return _IconsaxStopCircle!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxStopCircle: ImageVector? = null

View File

@ -0,0 +1,65 @@
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.IconsaxTag: ImageVector
get() {
if (_IconsaxTag != null) {
return _IconsaxTag!!
}
_IconsaxTag = ImageVector.Builder(
name = "IconsaxTag",
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(4.17f, 15.3f)
lineTo(8.7f, 19.83f)
curveTo(10.56f, 21.69f, 13.58f, 21.69f, 15.45f, 19.83f)
lineTo(19.84f, 15.44f)
curveTo(21.7f, 13.58f, 21.7f, 10.56f, 19.84f, 8.69f)
lineTo(15.3f, 4.17f)
curveTo(14.35f, 3.22f, 13.04f, 2.71f, 11.7f, 2.78f)
lineTo(6.7f, 3.02f)
curveTo(4.7f, 3.11f, 3.11f, 4.7f, 3.01f, 6.69f)
lineTo(2.77f, 11.69f)
curveTo(2.71f, 13.04f, 3.22f, 14.35f, 4.17f, 15.3f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(9.5f, 12.38f)
curveTo(11.091f, 12.38f, 12.38f, 11.091f, 12.38f, 9.5f)
curveTo(12.38f, 7.91f, 11.091f, 6.62f, 9.5f, 6.62f)
curveTo(7.91f, 6.62f, 6.62f, 7.91f, 6.62f, 9.5f)
curveTo(6.62f, 11.091f, 7.91f, 12.38f, 9.5f, 12.38f)
close()
}
}
}.build()
return _IconsaxTag!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxTag: ImageVector? = null

View File

@ -0,0 +1,87 @@
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.IconsaxTextalignLeft: ImageVector
get() {
if (_IconsaxTextalignLeft != null) {
return _IconsaxTextalignLeft!!
}
_IconsaxTextalignLeft = ImageVector.Builder(
name = "IconsaxTextalignLeft",
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)) {
moveTo(21f, 5.25f)
horizontalLineTo(3f)
curveTo(2.59f, 5.25f, 2.25f, 4.91f, 2.25f, 4.5f)
curveTo(2.25f, 4.09f, 2.59f, 3.75f, 3f, 3.75f)
horizontalLineTo(21f)
curveTo(21.41f, 3.75f, 21.75f, 4.09f, 21.75f, 4.5f)
curveTo(21.75f, 4.91f, 21.41f, 5.25f, 21f, 5.25f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(12.47f, 10.25f)
horizontalLineTo(3f)
curveTo(2.59f, 10.25f, 2.25f, 9.91f, 2.25f, 9.5f)
curveTo(2.25f, 9.09f, 2.59f, 8.75f, 3f, 8.75f)
horizontalLineTo(12.47f)
curveTo(12.88f, 8.75f, 13.22f, 9.09f, 13.22f, 9.5f)
curveTo(13.22f, 9.91f, 12.89f, 10.25f, 12.47f, 10.25f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(21f, 15.25f)
horizontalLineTo(3f)
curveTo(2.59f, 15.25f, 2.25f, 14.91f, 2.25f, 14.5f)
curveTo(2.25f, 14.09f, 2.59f, 13.75f, 3f, 13.75f)
horizontalLineTo(21f)
curveTo(21.41f, 13.75f, 21.75f, 14.09f, 21.75f, 14.5f)
curveTo(21.75f, 14.91f, 21.41f, 15.25f, 21f, 15.25f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(12.47f, 20.25f)
horizontalLineTo(3f)
curveTo(2.59f, 20.25f, 2.25f, 19.91f, 2.25f, 19.5f)
curveTo(2.25f, 19.09f, 2.59f, 18.75f, 3f, 18.75f)
horizontalLineTo(12.47f)
curveTo(12.88f, 18.75f, 13.22f, 19.09f, 13.22f, 19.5f)
curveTo(13.22f, 19.91f, 12.89f, 20.25f, 12.47f, 20.25f)
close()
}
}
}.build()
return _IconsaxTextalignLeft!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxTextalignLeft: ImageVector? = null

View File

@ -0,0 +1,130 @@
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.IconsaxVerticalSetting: ImageVector
get() {
if (_IconsaxVerticalSetting != null) {
return _IconsaxVerticalSetting!!
}
_IconsaxVerticalSetting = ImageVector.Builder(
name = "IconsaxVerticalSetting",
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(18.51f, 1.801f)
curveTo(18.01f, 1.801f, 17.61f, 2.201f, 17.61f, 2.701f)
verticalLineTo(7.351f)
curveTo(17.61f, 7.851f, 18.01f, 8.251f, 18.51f, 8.251f)
curveTo(19.01f, 8.251f, 19.41f, 7.851f, 19.41f, 7.351f)
verticalLineTo(2.701f)
curveTo(19.41f, 2.201f, 19.01f, 1.801f, 18.51f, 1.801f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 15.75f)
curveTo(11.5f, 15.75f, 11.1f, 16.15f, 11.1f, 16.65f)
verticalLineTo(21.3f)
curveTo(11.1f, 21.8f, 11.5f, 22.2f, 12f, 22.2f)
curveTo(12.5f, 22.2f, 12.9f, 21.8f, 12.9f, 21.3f)
verticalLineTo(16.65f)
curveTo(12.9f, 16.16f, 12.5f, 15.75f, 12f, 15.75f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(5.49f, 1.801f)
curveTo(4.99f, 1.801f, 4.59f, 2.201f, 4.59f, 2.701f)
verticalLineTo(7.351f)
curveTo(4.59f, 7.851f, 4.99f, 8.251f, 5.49f, 8.251f)
curveTo(5.99f, 8.251f, 6.39f, 7.851f, 6.39f, 7.351f)
verticalLineTo(2.701f)
curveTo(6.39f, 2.201f, 5.98f, 1.801f, 5.49f, 1.801f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(7.35f, 10.17f)
horizontalLineTo(3.63f)
curveTo(3.13f, 10.17f, 2.73f, 10.57f, 2.73f, 11.07f)
curveTo(2.73f, 11.57f, 3.13f, 11.97f, 3.63f, 11.97f)
horizontalLineTo(4.59f)
verticalLineTo(21.3f)
curveTo(4.59f, 21.8f, 4.99f, 22.2f, 5.49f, 22.2f)
curveTo(5.99f, 22.2f, 6.39f, 21.8f, 6.39f, 21.3f)
verticalLineTo(11.97f)
horizontalLineTo(7.35f)
curveTo(7.85f, 11.97f, 8.25f, 11.57f, 8.25f, 11.07f)
curveTo(8.25f, 10.57f, 7.84f, 10.17f, 7.35f, 10.17f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(20.37f, 10.17f)
horizontalLineTo(16.65f)
curveTo(16.15f, 10.17f, 15.75f, 10.57f, 15.75f, 11.07f)
curveTo(15.75f, 11.57f, 16.15f, 11.97f, 16.65f, 11.97f)
horizontalLineTo(17.61f)
verticalLineTo(21.3f)
curveTo(17.61f, 21.8f, 18.01f, 22.2f, 18.51f, 22.2f)
curveTo(19.01f, 22.2f, 19.41f, 21.8f, 19.41f, 21.3f)
verticalLineTo(11.97f)
horizontalLineTo(20.37f)
curveTo(20.87f, 11.97f, 21.27f, 11.57f, 21.27f, 11.07f)
curveTo(21.27f, 10.57f, 20.87f, 10.17f, 20.37f, 10.17f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(13.86f, 12.031f)
horizontalLineTo(12.9f)
verticalLineTo(2.701f)
curveTo(12.9f, 2.201f, 12.5f, 1.801f, 12f, 1.801f)
curveTo(11.5f, 1.801f, 11.1f, 2.201f, 11.1f, 2.701f)
verticalLineTo(12.031f)
horizontalLineTo(10.14f)
curveTo(9.64f, 12.031f, 9.24f, 12.431f, 9.24f, 12.931f)
curveTo(9.24f, 13.431f, 9.64f, 13.831f, 10.14f, 13.831f)
horizontalLineTo(13.86f)
curveTo(14.36f, 13.831f, 14.76f, 13.431f, 14.76f, 12.931f)
curveTo(14.76f, 12.431f, 14.36f, 12.031f, 13.86f, 12.031f)
close()
}
}
}.build()
return _IconsaxVerticalSetting!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxVerticalSetting: ImageVector? = null

View File

@ -0,0 +1,88 @@
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.IconsaxWifiSquare: ImageVector
get() {
if (_IconsaxWifiSquare != null) {
return _IconsaxWifiSquare!!
}
_IconsaxWifiSquare = ImageVector.Builder(
name = "IconsaxWifiSquare",
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(16.19f, 2f)
horizontalLineTo(7.82f)
curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f)
verticalLineTo(16.18f)
curveTo(2f, 19.82f, 4.17f, 21.99f, 7.81f, 21.99f)
horizontalLineTo(16.18f)
curveTo(19.82f, 21.99f, 21.99f, 19.82f, 21.99f, 16.18f)
verticalLineTo(7.81f)
curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(18f, 10.71f)
curveTo(17.84f, 10.71f, 17.68f, 10.66f, 17.54f, 10.55f)
curveTo(14.17f, 7.95f, 9.82f, 7.95f, 6.46f, 10.55f)
curveTo(6.13f, 10.8f, 5.66f, 10.74f, 5.41f, 10.42f)
curveTo(5.16f, 10.09f, 5.22f, 9.62f, 5.54f, 9.37f)
curveTo(9.46f, 6.34f, 14.53f, 6.34f, 18.46f, 9.37f)
curveTo(18.79f, 9.62f, 18.85f, 10.09f, 18.59f, 10.42f)
curveTo(18.45f, 10.61f, 18.22f, 10.71f, 18f, 10.71f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(16.4f, 13.8f)
curveTo(16.24f, 13.8f, 16.08f, 13.75f, 15.94f, 13.64f)
curveTo(13.54f, 11.79f, 10.45f, 11.79f, 8.05f, 13.64f)
curveTo(7.72f, 13.89f, 7.25f, 13.83f, 7f, 13.51f)
curveTo(6.75f, 13.19f, 6.81f, 12.71f, 7.13f, 12.46f)
curveTo(10.08f, 10.18f, 13.9f, 10.18f, 16.85f, 12.46f)
curveTo(17.18f, 12.71f, 17.24f, 13.18f, 16.98f, 13.51f)
curveTo(16.85f, 13.7f, 16.63f, 13.8f, 16.4f, 13.8f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(14.2f, 16.889f)
curveTo(14.04f, 16.889f, 13.88f, 16.84f, 13.74f, 16.729f)
curveTo(12.68f, 15.91f, 11.31f, 15.91f, 10.25f, 16.729f)
curveTo(9.92f, 16.979f, 9.45f, 16.92f, 9.2f, 16.6f)
curveTo(8.95f, 16.27f, 9.01f, 15.8f, 9.33f, 15.55f)
curveTo(10.92f, 14.319f, 13.06f, 14.319f, 14.65f, 15.55f)
curveTo(14.98f, 15.8f, 15.04f, 16.27f, 14.78f, 16.6f)
curveTo(14.65f, 16.789f, 14.43f, 16.889f, 14.2f, 16.889f)
close()
}
}
}.build()
return _IconsaxWifiSquare!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxWifiSquare: ImageVector? = null

View File

@ -0,0 +1,59 @@
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.path
import androidx.compose.ui.unit.dp
val Iconsax.LineDiscord: ImageVector
get() {
if (_LineDiscord != null) {
return _LineDiscord!!
}
_LineDiscord = ImageVector.Builder(
name = "LineDiscord",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(18.942f, 5.555f)
curveTo(17.647f, 4.95f, 16.263f, 4.509f, 14.816f, 4.259f)
curveTo(14.638f, 4.58f, 14.43f, 5.012f, 14.287f, 5.356f)
curveTo(12.749f, 5.125f, 11.224f, 5.125f, 9.714f, 5.356f)
curveTo(9.571f, 5.012f, 9.359f, 4.58f, 9.179f, 4.259f)
curveTo(7.731f, 4.509f, 6.345f, 4.951f, 5.05f, 5.559f)
curveTo(2.439f, 9.505f, 1.731f, 13.353f, 2.085f, 17.146f)
curveTo(3.817f, 18.439f, 5.495f, 19.225f, 7.145f, 19.739f)
curveTo(7.553f, 19.178f, 7.916f, 18.582f, 8.229f, 17.954f)
curveTo(7.633f, 17.728f, 7.062f, 17.448f, 6.523f, 17.124f)
curveTo(6.666f, 17.017f, 6.806f, 16.907f, 6.941f, 16.792f)
curveTo(10.232f, 18.332f, 13.807f, 18.332f, 17.059f, 16.792f)
curveTo(17.196f, 16.907f, 17.336f, 17.017f, 17.477f, 17.124f)
curveTo(16.936f, 17.45f, 16.364f, 17.729f, 15.767f, 17.956f)
curveTo(16.08f, 18.582f, 16.442f, 19.18f, 16.851f, 19.741f)
curveTo(18.503f, 19.227f, 20.183f, 18.441f, 21.915f, 17.146f)
curveTo(22.33f, 12.748f, 21.206f, 8.936f, 18.942f, 5.555f)
close()
moveTo(8.678f, 14.813f)
curveTo(7.69f, 14.813f, 6.88f, 13.891f, 6.88f, 12.768f)
curveTo(6.88f, 11.645f, 7.672f, 10.721f, 8.678f, 10.721f)
curveTo(9.683f, 10.721f, 10.493f, 11.643f, 10.476f, 12.768f)
curveTo(10.477f, 13.891f, 9.683f, 14.813f, 8.678f, 14.813f)
close()
moveTo(15.322f, 14.813f)
curveTo(14.334f, 14.813f, 13.524f, 13.891f, 13.524f, 12.768f)
curveTo(13.524f, 11.645f, 14.317f, 10.721f, 15.322f, 10.721f)
curveTo(16.327f, 10.721f, 17.138f, 11.643f, 17.12f, 12.768f)
curveTo(17.12f, 13.891f, 16.327f, 14.813f, 15.322f, 14.813f)
close()
}
}.build()
return _LineDiscord!!
}
@Suppress("ObjectPropertyName")
private var _LineDiscord: ImageVector? = null

View File

@ -0,0 +1,78 @@
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.path
import androidx.compose.ui.unit.dp
val Iconsax.PhosphorDatabaseDuotone: ImageVector
get() {
if (_PhosphorDatabaseDuotone != null) {
return _PhosphorDatabaseDuotone!!
}
_PhosphorDatabaseDuotone = ImageVector.Builder(
name = "PhosphorDatabaseDuotone",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 256f,
viewportHeight = 256f
).apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 0.2f,
strokeAlpha = 0.2f
) {
moveTo(216f, 80f)
curveToRelative(0f, 26.51f, -39.4f, 48f, -88f, 48f)
reflectiveCurveTo(40f, 106.51f, 40f, 80f)
reflectiveCurveToRelative(39.4f, -48f, 88f, -48f)
reflectiveCurveTo(216f, 53.49f, 216f, 80f)
close()
}
path(fill = SolidColor(Color.Black)) {
moveTo(128f, 24f)
curveTo(74.17f, 24f, 32f, 48.6f, 32f, 80f)
verticalLineToRelative(96f)
curveToRelative(0f, 31.4f, 42.17f, 56f, 96f, 56f)
reflectiveCurveToRelative(96f, -24.6f, 96f, -56f)
lineTo(224f, 80f)
curveTo(224f, 48.6f, 181.83f, 24f, 128f, 24f)
close()
moveTo(208f, 128f)
curveToRelative(0f, 9.62f, -7.88f, 19.43f, -21.61f, 26.92f)
curveTo(170.93f, 163.35f, 150.19f, 168f, 128f, 168f)
reflectiveCurveToRelative(-42.93f, -4.65f, -58.39f, -13.08f)
curveTo(55.88f, 147.43f, 48f, 137.62f, 48f, 128f)
lineTo(48f, 111.36f)
curveToRelative(17.06f, 15f, 46.23f, 24.64f, 80f, 24.64f)
reflectiveCurveToRelative(62.94f, -9.68f, 80f, -24.64f)
close()
moveTo(69.61f, 53.08f)
curveTo(85.07f, 44.65f, 105.81f, 40f, 128f, 40f)
reflectiveCurveToRelative(42.93f, 4.65f, 58.39f, 13.08f)
curveTo(200.12f, 60.57f, 208f, 70.38f, 208f, 80f)
reflectiveCurveToRelative(-7.88f, 19.43f, -21.61f, 26.92f)
curveTo(170.93f, 115.35f, 150.19f, 120f, 128f, 120f)
reflectiveCurveToRelative(-42.93f, -4.65f, -58.39f, -13.08f)
curveTo(55.88f, 99.43f, 48f, 89.62f, 48f, 80f)
reflectiveCurveTo(55.88f, 60.57f, 69.61f, 53.08f)
close()
moveTo(186.39f, 202.92f)
curveTo(170.93f, 211.35f, 150.19f, 216f, 128f, 216f)
reflectiveCurveToRelative(-42.93f, -4.65f, -58.39f, -13.08f)
curveTo(55.88f, 195.43f, 48f, 185.62f, 48f, 176f)
lineTo(48f, 159.36f)
curveToRelative(17.06f, 15f, 46.23f, 24.64f, 80f, 24.64f)
reflectiveCurveToRelative(62.94f, -9.68f, 80f, -24.64f)
lineTo(208f, 176f)
curveTo(208f, 185.62f, 200.12f, 195.43f, 186.39f, 202.92f)
close()
}
}.build()
return _PhosphorDatabaseDuotone!!
}
@Suppress("ObjectPropertyName")
private var _PhosphorDatabaseDuotone: ImageVector? = null

View File

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

View File

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