diff --git a/assets/images/album-placeholder.png b/assets/images/album-placeholder.png
deleted file mode 100644
index bcb25969..00000000
Binary files a/assets/images/album-placeholder.png and /dev/null differ
diff --git a/assets/images/bengali-patterns-bg.jpg b/assets/images/bengali-patterns-bg.jpg
deleted file mode 100644
index a4090a01..00000000
Binary files a/assets/images/bengali-patterns-bg.jpg and /dev/null differ
diff --git a/assets/images/logos/dab-music.png b/assets/images/logos/dab-music.png
deleted file mode 100644
index e09d3410..00000000
Binary files a/assets/images/logos/dab-music.png and /dev/null differ
diff --git a/assets/images/logos/invidious.jpg b/assets/images/logos/invidious.jpg
deleted file mode 100644
index 3a54ace1..00000000
Binary files a/assets/images/logos/invidious.jpg and /dev/null differ
diff --git a/assets/images/logos/jiosaavn.png b/assets/images/logos/jiosaavn.png
deleted file mode 100644
index 4d2d46e4..00000000
Binary files a/assets/images/logos/jiosaavn.png and /dev/null differ
diff --git a/assets/images/placeholder.png b/assets/images/placeholder.png
deleted file mode 100644
index 6f6d451f..00000000
Binary files a/assets/images/placeholder.png and /dev/null differ
diff --git a/assets/images/user-placeholder.png b/assets/images/user-placeholder.png
deleted file mode 100644
index cb2325ff..00000000
Binary files a/assets/images/user-placeholder.png and /dev/null differ
diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png
new file mode 100644
index 00000000..9da9cb69
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png differ
diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg
new file mode 100644
index 00000000..7cb4554d
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.svg
@@ -0,0 +1,17 @@
+
+
+
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index b636db4f..75571344 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -26,6 +26,7 @@
Caching
Desktop
Updates
+ About
Close
Save
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt
index 63dc38ec..20f9af7f 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt
@@ -106,6 +106,8 @@ fun DropdownMenuItem(
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
+ leadingContent: (@Composable () -> Unit)? = null,
+ subtitle: String? = null,
selected: Boolean = false,
theme: BaseUITheme.DropdownMenuTheme? = null,
) {
@@ -142,8 +144,9 @@ fun DropdownMenuItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
- if (leadingIcon != null) {
- Icon(
+ when {
+ leadingContent != null -> leadingContent()
+ leadingIcon != null -> Icon(
imageVector = leadingIcon,
contentDescription = null,
modifier = Modifier.size(18.dp),
@@ -155,18 +158,28 @@ fun DropdownMenuItem(
)
}
- 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,
- )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (enabled) {
+ menuTheme.itemForeground
+ } else {
+ menuTheme.itemForeground.copy(alpha = 0.38f)
+ },
+ maxLines = 1,
+ softWrap = false,
+ )
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.labelSmall,
+ color = menuTheme.itemForeground.copy(alpha = if (enabled) 0.6f else 0.38f),
+ maxLines = 1,
+ softWrap = false,
+ )
+ }
+ }
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt
index b87433f7..cc1068aa 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt
@@ -82,6 +82,8 @@ data class AdaptiveMenuItem(
val enabled: Boolean = true,
val selected: Boolean = false,
val dividerBefore: Boolean = false,
+ val subtitle: String? = null,
+ val leadingContent: (@Composable () -> Unit)? = null,
)
enum class HeaderDisplayMode {
@@ -156,6 +158,8 @@ fun AdaptiveDropdownBottomSheet(
enabled = item.enabled,
selected = item.selected,
leadingIcon = item.icon,
+ leadingContent = item.leadingContent,
+ subtitle = item.subtitle,
)
}
}
@@ -261,33 +265,32 @@ private fun AdaptiveBottomSheetContent(
)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- if (item.icon != null) {
- Icon(
- imageVector = item.icon,
- contentDescription = null,
- modifier = Modifier.size(22.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(22.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- } else if (hasSelection) {
- Spacer(modifier = Modifier.size(22.dp))
- }
+ when {
+ item.leadingContent != null -> item.leadingContent.invoke()
+ item.icon != null -> Icon(
+ imageVector = item.icon,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = if (item.enabled) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ },
+ )
+
+ item.selected -> Icon(
+ imageVector = FeatherIcons.Check,
+ contentDescription = "Selected",
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ hasSelection -> Spacer(modifier = Modifier.size(22.dp))
+ }
+
+ Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
style = MaterialTheme.typography.bodyLarge,
@@ -297,9 +300,16 @@ private fun AdaptiveBottomSheetContent(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
+ item.subtitle?.let { subtitle ->
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
- if (item.selected && item.icon != null) {
+ if (item.selected && (item.icon != null || item.leadingContent != null)) {
Icon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt
new file mode 100644
index 00000000..aae13619
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.core.ui.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
+import dev.krtirtho.spotube.core.ui.base.OutlineButton
+import dev.krtirtho.spotube.core.ui.base.SecondaryButton
+import dev.krtirtho.spotube.core.ui.base.copyPadding
+import dev.krtirtho.spotube.core.ui.base.copyShape
+
+/**
+ * The shared navigation item used by every sidebar in the app (the main app sidebar
+ * and the settings sidebar), so they stay visually identical.
+ *
+ * @param icon leading icon of the item.
+ * @param badge optional overlay drawn on the icon (for example a download indicator).
+ */
+@Composable
+fun SidebarItem(
+ label: String,
+ icon: ImageVector,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ expanded: Boolean = true,
+ badge: (@Composable BoxScope.() -> Unit)? = null,
+) {
+ val itemContent: @Composable RowScope.() -> Unit = {
+ Box(modifier = Modifier.size(16.dp)) {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = if (selected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ badge?.invoke(this)
+ }
+ AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
+ Text(
+ text = label,
+ maxLines = 1,
+ softWrap = false,
+ style = LocalTextStyle.current.copy(
+ fontSize = MaterialTheme.typography.labelMedium.fontSize,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
+ color = if (selected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ )
+ }
+ Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
+ }
+
+ val buttonModifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 7.dp, vertical = 2.dp)
+ val contentPadding = PaddingValues(horizontal = 5.dp)
+
+ if (selected) {
+ SecondaryButton(
+ onClick = onClick,
+ modifier = buttonModifier,
+ theme = LocalBaseUITheme.current.buttons.secondary
+ .copyPadding(contentPadding)
+ .copyShape(RoundedCornerShape(5.dp)),
+ content = itemContent,
+ )
+ } else {
+ OutlineButton(
+ onClick = onClick,
+ modifier = buttonModifier,
+ theme = LocalBaseUITheme.current.buttons.outline
+ .copyPadding(contentPadding)
+ .copyShape(RoundedCornerShape(5.dp)),
+ hoverOnly = true,
+ content = itemContent,
+ )
+ }
+}
+
+/**
+ * The shared section heading used above groups of [SidebarItem]s.
+ */
+@Composable
+fun SidebarSectionLabel(
+ text: String,
+ expanded: Boolean = true,
+) {
+ AnimatedVisibility(visible = expanded) {
+ Text(
+ text = text.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 8.dp),
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt
index 087e02ec..8aad2670 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt
@@ -67,6 +67,30 @@ import org.koin.compose.viewmodel.koinViewModel
@Composable
fun BlacklistScreen() {
val viewModel = koinViewModel()
+ Scaffold(
+ topBar = {
+ ApplicationMainBar(
+ backButton = true,
+ title = { Text("Blacklist") },
+ )
+ },
+ ) { innerPadding ->
+ BlacklistContent(
+ viewModel = viewModel,
+ modifier = Modifier.padding(innerPadding),
+ )
+ }
+}
+
+/**
+ * The blacklist UI without its own [Scaffold] / app bar, so it can be embedded
+ * as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen].
+ */
+@Composable
+internal fun BlacklistContent(
+ viewModel: BlacklistViewModel,
+ modifier: Modifier = Modifier,
+) {
val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle()
val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current
@@ -102,92 +126,82 @@ fun BlacklistScreen() {
val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty()
val hasActiveFilter = normalizedQuery.isNotBlank()
- Scaffold(
- topBar = {
- ApplicationMainBar(
- backButton = true,
- title = { Text("Blacklist") },
- )
- },
- ) { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- TextField(
- value = filterQuery,
- onValueChange = { filterQuery = it },
- modifier = Modifier.fillMaxWidth(),
- placeholder = { Text("Filter blacklist...") },
- singleLine = true,
- leadingIcon = {
- Icon(
- imageVector = Iconsax.IconsaxFilterSearch,
- contentDescription = "Search",
- )
- },
- )
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ TextField(
+ value = filterQuery,
+ onValueChange = { filterQuery = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("Filter blacklist...") },
+ singleLine = true,
+ leadingIcon = {
+ Icon(
+ imageVector = Iconsax.IconsaxFilterSearch,
+ contentDescription = "Search",
+ )
+ },
+ )
- if (isEmpty) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(bottom = shellBottomInset),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = if (hasActiveFilter) {
- "No blacklisted items match '$filterQuery'"
- } else {
- "No blacklisted items"
- },
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- } else {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- contentPadding = androidx.compose.foundation.layout.PaddingValues(
- bottom = 16.dp + shellBottomInset,
- ),
- ) {
- if (filteredTracks.isNotEmpty()) {
- item {
- Text(
- text = "Tracks",
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(vertical = 8.dp),
- )
- }
- items(filteredTracks, key = { "track_${it.id}" }) { track ->
- BlacklistedTrackRow(
- track = track,
- onRemove = { viewModel.toggleTrack(track) },
- )
- }
+ if (isEmpty) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = shellBottomInset),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = if (hasActiveFilter) {
+ "No blacklisted items match '$filterQuery'"
+ } else {
+ "No blacklisted items"
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
+ bottom = 16.dp + shellBottomInset,
+ ),
+ ) {
+ if (filteredTracks.isNotEmpty()) {
+ item {
+ Text(
+ text = "Tracks",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
}
+ items(filteredTracks, key = { "track_${it.id}" }) { track ->
+ BlacklistedTrackRow(
+ track = track,
+ onRemove = { viewModel.toggleTrack(track) },
+ )
+ }
+ }
- if (filteredArtists.isNotEmpty()) {
- item {
- Text(
- text = "Artists",
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(vertical = 8.dp),
- )
- }
- items(filteredArtists, key = { "artist_${it.id}" }) { artist ->
- BlacklistedArtistRow(
- artist = artist,
- onRemove = { viewModel.toggleArtist(artist) },
- )
- }
+ if (filteredArtists.isNotEmpty()) {
+ item {
+ Text(
+ text = "Artists",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+ }
+ items(filteredArtists, key = { "artist_${it.id}" }) { artist ->
+ BlacklistedArtistRow(
+ artist = artist,
+ onRemove = { viewModel.toggleArtist(artist) },
+ )
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt
index 9a642cd6..b43b5c52 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt
@@ -17,6 +17,11 @@
package dev.krtirtho.spotube.modules.plugin
+import org.jetbrains.compose.resources.DrawableResource
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.plugin_logo_lrclib
+import spotube.composeapp.generated.resources.plugin_logo_newpipe
+
val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
name = "NewPipe YouTube",
version = "0.1.0",
@@ -52,3 +57,15 @@ val BUILT_IN_PLUGINS = listOf(
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
LRCLIB_BUILT_IN_PLUGIN,
)
+
+/**
+ * The bundled placeholder logo for a built-in plugin, or `null` if it has none.
+ *
+ * Built-in plugins are not installed to disk, so they have no `logo.png`; these
+ * resources (in `composeResources/drawable`) stand in as their logos.
+ */
+fun builtInPluginLogo(plugin: PluginEntry): DrawableResource? = when (plugin.id) {
+ NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_newpipe
+ LRCLIB_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_lrclib
+ else -> null
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt
index ede1fb60..8fcd5baf 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt
@@ -75,9 +75,14 @@ import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.getPlatform
+import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog
+import dev.krtirtho.spotube.modules.plugin.components.PluginLogo
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
+import dev.krtirtho.spotube.modules.plugin.components.abilityIcon
+import dev.krtirtho.spotube.modules.plugin.components.accentColor
+import dev.krtirtho.spotube.modules.plugin.components.displayLabel
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.openUrlInBrowser
import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo
@@ -86,16 +91,12 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
-import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
-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.compose.rememberFilePickerLauncher
import okio.Path
@@ -127,10 +128,6 @@ import spotube.composeapp.generated.resources.plugin_section_file_title
import spotube.composeapp.generated.resources.plugin_section_install
import spotube.composeapp.generated.resources.plugin_section_url_title
import spotube.composeapp.generated.resources.plugin_url_placeholder
-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_action_change
import spotube.composeapp.generated.resources.settings_plugins_action_select
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
@@ -147,6 +144,32 @@ private val VERIFIED_PLUGIN_OWNERS = setOf()
fun PluginScreen(
viewModel: PluginViewModel = koinViewModel(),
onboarding: Boolean = false,
+) {
+ Scaffold(
+ topBar = {
+ if (!onboarding) {
+ ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
+ }
+ },
+ containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
+ ) { innerPadding ->
+ PluginScreenContent(
+ viewModel = viewModel,
+ onboarding = onboarding,
+ modifier = Modifier.padding(innerPadding),
+ )
+ }
+}
+
+/**
+ * The plugin management UI without its own [Scaffold] / app bar, so it can be embedded
+ * as a tab in the large-screen settings layout as well as hosted by [PluginScreen].
+ */
+@Composable
+internal fun PluginScreenContent(
+ viewModel: PluginViewModel,
+ onboarding: Boolean = false,
+ modifier: Modifier = Modifier,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current
@@ -222,28 +245,17 @@ fun PluginScreen(
}
}
- Scaffold(
- topBar = {
- if (!onboarding) {
- ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
- }
- },
- containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
- ) { innerPadding ->
+ Box(modifier = modifier.fillMaxSize()) {
when (val data = uiState) {
is PluginUiState.Loading -> {
Box(
- modifier = Modifier.fillMaxSize().padding(innerPadding),
+ modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is PluginUiState.Data -> {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- ) {
+ Box(modifier = Modifier.fillMaxSize()) {
val discoverListState = rememberLazyListState()
LazyColumn(
state = discoverListState,
@@ -302,6 +314,19 @@ fun PluginScreen(
}
}
if (!onboarding || !data.onboardingDiscover) {
+
+ item {
+ DefaultAbilityPluginGrid(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ selections = data.abilitySelections,
+ onSelected = { ability, plugin ->
+ viewModel.selectPlugin(ability, plugin)
+ },
+ )
+ }
+
item {
Row(
modifier = Modifier
@@ -325,39 +350,6 @@ fun PluginScreen(
}
}
- item {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp),
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 4.dp, bottom = 4.dp)
- ) {
- data.abilitySelections.forEachIndexed { index, selection ->
- if (index > 0) {
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant.copy(
- alpha = 0.5f
- ),
- )
- }
- DefaultAbilityPluginSelector(
- selection = selection,
- onSelected = { plugin ->
- viewModel.selectPlugin(
- selection.ability,
- plugin
- )
- },
- )
- }
- }
- }
- }
-
if (data.plugins.isEmpty()) {
item {
Box(
@@ -721,26 +713,22 @@ private fun PluginInfoDialog(
.clip(RoundedCornerShape(12.dp)),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
) {
- if (logoPath != null) {
- val platformContext = LocalPlatformContext.current
- AsyncImage(
- model = ImageRequest.Builder(platformContext)
- .data(logoPath.toString())
- .crossfade(true)
- .build(),
- contentDescription = plugin.name,
- modifier = Modifier.fillMaxSize()
- )
- } else {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- Iconsax.IconsaxBox,
- contentDescription = null,
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- }
- }
+ PluginLogo(
+ plugin = plugin,
+ logoPath = logoPath,
+ contentDescription = plugin.name,
+ modifier = Modifier.fillMaxSize(),
+ fallback = {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Iconsax.IconsaxBox,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ },
+ )
}
Text(
plugin.name,
@@ -1135,31 +1123,16 @@ fun DefaultAbilityPluginSelector(
) {
Surface(
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
- color = when (selection.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)
- }
+ color = selection.ability.accentColor().copy(alpha = 0.1f)
) {
Icon(
- imageVector = when (selection.ability) {
- PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
- PluginAbility.AUDIO -> Iconsax.IconsaxMusic
- PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
- PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
- },
+ imageVector = selection.ability.abilityIcon(),
contentDescription = stringResource(
Res.string.settings_plugins_plugin_content_description,
selection.ability.displayLabel()
),
modifier = Modifier.padding(8.dp),
- tint = when (selection.ability) {
- PluginAbility.METADATA -> Color(0xFF4CAF50)
- PluginAbility.AUDIO -> Color(0xFF2196F3)
- PluginAbility.LYRICS -> Color(0xFFFFC107)
- PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
- }
+ tint = selection.ability.accentColor()
)
}
@@ -1203,28 +1176,13 @@ fun DefaultAbilityPluginSelector(
) {
Surface(
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
- color = when (selection.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)
- }
+ color = selection.ability.accentColor().copy(alpha = 0.1f)
) {
Icon(
- imageVector = when (selection.ability) {
- PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
- PluginAbility.AUDIO -> Iconsax.IconsaxMusic
- PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
- PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
- },
+ imageVector = selection.ability.abilityIcon(),
contentDescription = null,
modifier = Modifier.padding(8.dp),
- tint = when (selection.ability) {
- PluginAbility.METADATA -> Color(0xFF4CAF50)
- PluginAbility.AUDIO -> Color(0xFF2196F3)
- PluginAbility.LYRICS -> Color(0xFFFFC107)
- PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
- }
+ tint = selection.ability.accentColor()
)
}
Column(modifier = Modifier.weight(1f)) {
@@ -1265,12 +1223,3 @@ fun DefaultAbilityPluginSelector(
}
}
-@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)
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt
index 2f8917bb..4bc032b3 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt
@@ -63,6 +63,7 @@ data class AbilitySelection(
val ability: PluginAbility,
val plugins: List,
val selectedPlugin: PluginEntry?,
+ val pluginLogoPaths: Map = emptyMap(),
)
data class PluginDiscoverState(
@@ -254,22 +255,26 @@ class PluginViewModel(
AbilitySelection(
PluginAbility.METADATA,
snapshot.metadataPlugins,
- state.selectedPlugins[PluginAbility.METADATA]
+ state.selectedPlugins[PluginAbility.METADATA],
+ logoPaths,
),
AbilitySelection(
PluginAbility.AUDIO,
snapshot.audioPlugins,
- state.selectedPlugins[PluginAbility.AUDIO]
+ state.selectedPlugins[PluginAbility.AUDIO],
+ logoPaths,
),
AbilitySelection(
PluginAbility.LYRICS,
snapshot.lyricsPlugins,
- state.selectedPlugins[PluginAbility.LYRICS]
+ state.selectedPlugins[PluginAbility.LYRICS],
+ logoPaths,
),
AbilitySelection(
PluginAbility.SCROBBLE,
snapshot.scrobblePlugins,
- state.selectedPlugins[PluginAbility.SCROBBLE]
+ state.selectedPlugins[PluginAbility.SCROBBLE],
+ logoPaths,
),
),
pendingPlugin = snapshot.pendingPlugin,
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt
new file mode 100644
index 00000000..9f835868
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt
@@ -0,0 +1,420 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.modules.plugin.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+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 dev.krtirtho.spotube.modules.plugin.AbilitySelection
+import dev.krtirtho.spotube.modules.plugin.PluginAbility
+import dev.krtirtho.spotube.modules.plugin.PluginEntry
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
+import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
+import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
+import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
+import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft
+import okio.Path
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.Res
+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_default_ability_title
+import spotube.composeapp.generated.resources.settings_plugins_no_plugins
+import spotube.composeapp.generated.resources.settings_plugins_no_selection
+import kotlin.random.Random
+
+/** The accent color used for a plugin ability across the whole plugin UI. */
+internal fun PluginAbility.accentColor(): Color = when (this) {
+ PluginAbility.METADATA -> Color(0xFF34A853)
+ PluginAbility.AUDIO -> Color(0xFF1E88E5)
+ PluginAbility.LYRICS -> Color(0xFFF9A825)
+ PluginAbility.SCROBBLE -> Color(0xFF8E24AA)
+}
+
+/** The icon used to represent a plugin ability. */
+internal fun PluginAbility.abilityIcon(): ImageVector = when (this) {
+ PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
+ PluginAbility.AUDIO -> Iconsax.IconsaxMusic
+ PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
+ PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
+}
+
+/**
+ * A vibrant, multi-hue gradient per ability, used by the Frutiger-Aero plugin tiles.
+ */
+internal fun PluginAbility.aeroGradient(): List = when (this) {
+ PluginAbility.METADATA -> listOf(Color(0xFFA8E063), Color(0xFF56AB2F), Color(0xFF11998E))
+ PluginAbility.AUDIO -> listOf(Color(0xFF56CCF2), Color(0xFF2F80ED), Color(0xFF6A11CB))
+ PluginAbility.LYRICS -> listOf(Color(0xFFFFE259), Color(0xFFFFA751), Color(0xFFFF5E62))
+ PluginAbility.SCROBBLE -> listOf(Color(0xFFFF6FD8), Color(0xFF9D50BB), Color(0xFF3F0D7A))
+}
+
+@Composable
+internal fun PluginAbility.displayLabel(): String = 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)
+}
+
+/** A short "v1.2.3 · Author" summary shown under a plugin in the picker. */
+private fun PluginEntry.metadataLabel(): String = buildString {
+ append("v").append(version)
+ if (author.isNotBlank()) {
+ append(" · ").append(author)
+ }
+}
+
+/**
+ * The 2x2 grid of default-plugin selectors shown on large screens. Each tile is a
+ * Frutiger-Aero style card representing one ability and its currently selected plugin.
+ */
+@Composable
+internal fun DefaultAbilityPluginGrid(
+ selections: List,
+ onSelected: (PluginAbility, PluginEntry?) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ selections.chunked(2).forEach { rowSelections ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ rowSelections.forEach { selection ->
+ DefaultAbilityPluginCard(
+ selection = selection,
+ onSelected = { onSelected(selection.ability, it) },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ repeat(2 - rowSelections.size) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DefaultAbilityPluginCard(
+ selection: AbilitySelection,
+ onSelected: (PluginEntry?) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val ability = selection.ability
+ val selected = selection.selectedPlugin
+ val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins)
+ val noSelectionText = stringResource(Res.string.settings_plugins_no_selection)
+
+ val menuItems = buildList {
+ if (selection.plugins.isNotEmpty()) {
+ selection.plugins.forEach { plugin ->
+ add(
+ AdaptiveMenuItem(
+ label = plugin.name,
+ subtitle = plugin.metadataLabel(),
+ leadingContent = {
+ Box(
+ modifier = Modifier
+ .size(30.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ PluginLogo(
+ plugin = plugin,
+ logoPath = selection.pluginLogoPaths[plugin.id],
+ contentDescription = plugin.name,
+ modifier = Modifier.fillMaxSize(),
+ fallback = {
+ Icon(
+ imageVector = ability.abilityIcon(),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = ability.accentColor(),
+ )
+ },
+ )
+ }
+ },
+ onClick = { onSelected(plugin) },
+ selected = selected?.name == plugin.name,
+ )
+ )
+ }
+ } else {
+ add(
+ AdaptiveMenuItem(
+ label = noPluginsText,
+ onClick = { },
+ enabled = false,
+ )
+ )
+ }
+ }
+
+ AdaptiveDropdownBottomSheet(
+ items = menuItems,
+ modifier = modifier,
+ headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
+ header = {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ AbilityBadge(ability)
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ stringResource(
+ Res.string.settings_plugins_default_ability_title,
+ ability.displayLabel(),
+ ),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ selected?.let {
+ Text(
+ it.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ },
+ trigger = { onClick ->
+ AbilityPluginTile(
+ ability = ability,
+ selectedPlugin = selected,
+ logoPath = selection.pluginLogoPaths[selected?.id],
+ pluginName = selected?.name ?: noSelectionText,
+ version = selected?.version,
+ onClick = onClick,
+ )
+ },
+ )
+}
+
+@Composable
+private fun AbilityPluginTile(
+ ability: PluginAbility,
+ selectedPlugin: PluginEntry?,
+ logoPath: Path?,
+ pluginName: String,
+ version: String?,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val accent = ability.accentColor()
+ val gradient = ability.aeroGradient()
+ val shape = RoundedCornerShape(20.dp)
+ val seed = ability.ordinal + 1
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .clickable(onClick = onClick)
+ .heightIn(min = 148.dp)
+ .drawWithCache {
+ val background = Brush.linearGradient(
+ colors = gradient,
+ start = Offset.Zero,
+ end = Offset(size.width, size.height),
+ )
+ onDrawBehind {
+ drawRect(background)
+ drawAeroDecorations(seed)
+ // Bottom scrim so the white text stays readable on light gradients.
+ drawRect(
+ brush = Brush.verticalGradient(
+ colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.32f)),
+ startY = size.height * 0.3f,
+ endY = size.height,
+ ),
+ )
+ // Glossy top highlight.
+ drawRect(
+ brush = Brush.verticalGradient(
+ colors = listOf(Color.White.copy(alpha = 0.35f), Color.Transparent),
+ startY = 0f,
+ endY = size.height * 0.55f,
+ ),
+ )
+ }
+ },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(18.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(52.dp)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.35f))
+ .border(1.dp, Color.White.copy(alpha = 0.6f), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ PluginLogo(
+ plugin = selectedPlugin,
+ logoPath = logoPath,
+ contentDescription = pluginName,
+ modifier = Modifier.fillMaxSize(),
+ fallback = {
+ Icon(
+ imageVector = ability.abilityIcon(),
+ contentDescription = null,
+ modifier = Modifier.size(26.dp),
+ tint = accent,
+ )
+ },
+ )
+ }
+ Icon(
+ imageVector = Iconsax.IconsaxArrowDown4,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = Color.White.copy(alpha = 0.85f),
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = pluginName,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = buildString {
+ append(ability.displayLabel())
+ if (!version.isNullOrBlank()) {
+ append(" · v").append(version)
+ }
+ },
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White.copy(alpha = 0.85f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AbilityBadge(ability: PluginAbility) {
+ val accent = ability.accentColor()
+ Surface(
+ modifier = Modifier.clip(RoundedCornerShape(8.dp)),
+ color = accent.copy(alpha = 0.12f),
+ ) {
+ Icon(
+ imageVector = ability.abilityIcon(),
+ contentDescription = null,
+ modifier = Modifier.padding(8.dp),
+ tint = accent,
+ )
+ }
+}
+
+/**
+ * Soft translucent bubbles and highlights behind a tile, giving the glossy
+ * Frutiger-Aero look. Deterministic per tile so it does not flicker on recomposition.
+ */
+private fun DrawScope.drawAeroDecorations(seed: Int) {
+ val random = Random(seed)
+ repeat(6) {
+ val center = Offset(
+ x = random.nextFloat() * size.width,
+ y = random.nextFloat() * size.height,
+ )
+ val radius = size.minDimension * (0.12f + random.nextFloat() * 0.4f)
+ drawCircle(
+ brush = Brush.radialGradient(
+ colors = listOf(Color.White.copy(alpha = 0.3f), Color.Transparent),
+ center = center,
+ radius = radius,
+ ),
+ radius = radius,
+ center = center,
+ )
+ }
+ repeat(3) {
+ val center = Offset(
+ x = random.nextFloat() * size.width,
+ y = random.nextFloat() * size.height,
+ )
+ val radius = size.minDimension * (0.03f + random.nextFloat() * 0.05f)
+ drawCircle(
+ color = Color.White.copy(alpha = 0.28f),
+ radius = radius,
+ center = center,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt
index ec481a72..b2a34f72 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt
@@ -39,10 +39,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
-import coil3.compose.LocalPlatformContext
-import coil3.request.ImageRequest
-import coil3.request.crossfade
import okio.Path
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.OutlineButton
@@ -65,10 +61,6 @@ import spotube.composeapp.generated.resources.plugin_action_logout
import spotube.composeapp.generated.resources.plugin_state_active
import spotube.composeapp.generated.resources.plugin_state_builtin
import spotube.composeapp.generated.resources.plugin_version_label
-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
@Composable
internal fun PluginCard(
@@ -96,26 +88,22 @@ internal fun PluginCard(
.clip(RoundedCornerShape(10.dp)),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
- if (logoPath != null) {
- val platformContext = LocalPlatformContext.current
- AsyncImage(
- model = ImageRequest.Builder(platformContext)
- .data(logoPath.toString())
- .crossfade(true)
- .build(),
- contentDescription = plugin.name,
- modifier = Modifier.fillMaxSize()
- )
- } else {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- Iconsax.IconsaxBox,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(22.dp)
- )
- }
- }
+ PluginLogo(
+ plugin = plugin,
+ logoPath = logoPath,
+ contentDescription = plugin.name,
+ modifier = Modifier.fillMaxSize(),
+ fallback = {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Iconsax.IconsaxBox,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ },
+ )
}
// Text content
@@ -302,12 +290,3 @@ internal fun PluginCard(
}
}
-@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)
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt
new file mode 100644
index 00000000..6db4d779
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.modules.plugin.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import coil3.compose.AsyncImage
+import coil3.compose.LocalPlatformContext
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import dev.krtirtho.spotube.modules.plugin.PluginEntry
+import dev.krtirtho.spotube.modules.plugin.builtInPluginLogo
+import okio.Path
+import org.jetbrains.compose.resources.painterResource
+
+/**
+ * Renders a plugin's logo, resolving the best available source:
+ *
+ * 1. the plugin's own `logo.png` ([logoPath]) when it was installed from disk,
+ * 2. the bundled placeholder logo for built-in plugins (see [builtInPluginLogo]),
+ * 3. [fallback] (for example the ability icon) when no logo is available.
+ */
+@Composable
+fun PluginLogo(
+ plugin: PluginEntry?,
+ logoPath: Path?,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+ contentScale: ContentScale = ContentScale.Crop,
+ fallback: @Composable () -> Unit,
+) {
+ val builtInLogo = plugin?.let { builtInPluginLogo(it) }
+
+ when {
+ logoPath != null -> {
+ val platformContext = LocalPlatformContext.current
+ AsyncImage(
+ model = ImageRequest.Builder(platformContext)
+ .data(logoPath.toString())
+ .crossfade(true)
+ .build(),
+ contentDescription = contentDescription,
+ contentScale = contentScale,
+ modifier = modifier,
+ )
+ }
+
+ builtInLogo != null -> {
+ Image(
+ painter = painterResource(builtInLogo),
+ contentDescription = contentDescription,
+ contentScale = contentScale,
+ modifier = modifier,
+ )
+ }
+
+ else -> fallback()
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt
index b641fe89..e4f065d0 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt
@@ -39,10 +39,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
-import coil3.compose.LocalPlatformContext
-import coil3.request.ImageRequest
-import coil3.request.crossfade
import okio.Path
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.OutlineButton
@@ -100,24 +96,20 @@ fun PluginPermissionDialog(
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center
) {
- if (logoPath != null) {
- val platformContext = LocalPlatformContext.current
- AsyncImage(
- model = ImageRequest.Builder(platformContext)
- .data(logoPath.toString())
- .crossfade(true)
- .build(),
- contentDescription = pluginInfo.name,
- modifier = Modifier.fillMaxSize()
- )
- } else {
- Icon(
- imageVector = Iconsax.IconsaxBoxAdd,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(24.dp)
- )
- }
+ PluginLogo(
+ plugin = pluginInfo,
+ logoPath = logoPath,
+ contentDescription = pluginInfo.name,
+ modifier = Modifier.fillMaxSize(),
+ fallback = {
+ Icon(
+ imageVector = Iconsax.IconsaxBoxAdd,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ },
+ )
}
Column(modifier = Modifier.weight(1f)) {
Text(
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt
index 3264af61..6630b844 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt
@@ -59,13 +59,6 @@ import spotube.composeapp.generated.resources.settings_section_jam
fun JamSettingsScreen(
viewModel: JamSettingsViewModel = koinViewModel(),
) {
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- val broker = uiState.settings.jamBroker
- val shellBottomInset = LocalAppShellBottomInset.current
- val contentPadding = remember(shellBottomInset) {
- PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
- }
-
Scaffold(
topBar = {
ApplicationMainBar(
@@ -73,201 +66,222 @@ fun JamSettingsScreen(
)
},
) { innerPadding ->
- Box(
+ JamSettingsContent(
+ viewModel = viewModel,
+ modifier = Modifier.padding(innerPadding),
+ )
+ }
+}
+
+/**
+ * The Group Jam settings UI without its own [Scaffold] / app bar, so it can be embedded
+ * as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen].
+ */
+@Composable
+internal fun JamSettingsContent(
+ viewModel: JamSettingsViewModel = koinViewModel(),
+ modifier: Modifier = Modifier,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val broker = uiState.settings.jamBroker
+ val shellBottomInset = LocalAppShellBottomInset.current
+ val contentPadding = remember(shellBottomInset) {
+ PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize(),
+ ) {
+ LazyColumn(
modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
+ .widthIn(max = 1280.dp)
+ .align(Alignment.TopCenter),
+ contentPadding = contentPadding,
) {
- LazyColumn(
- modifier = Modifier
- .widthIn(max = 1280.dp)
- .align(Alignment.TopCenter),
- contentPadding = contentPadding,
- ) {
- settingsSectionHeader(Res.string.settings_jam_section_connection)
- settingsSectionCard(
- items = listOf(
- {
- TextInputSettingCard(
- title = stringResource(Res.string.settings_jam_broker_host),
- value = broker.host,
- placeholder = "broker.example.com",
- icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") },
- onValueSaved = { host ->
- viewModel.update {
- copy(
- jamBroker = jamBroker.copy(
- host = host
- )
+ settingsSectionHeader(Res.string.settings_jam_section_connection)
+ settingsSectionCard(
+ items = listOf(
+ {
+ TextInputSettingCard(
+ title = stringResource(Res.string.settings_jam_broker_host),
+ value = broker.host,
+ placeholder = "broker.example.com",
+ icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") },
+ onValueSaved = { host ->
+ viewModel.update {
+ copy(
+ jamBroker = jamBroker.copy(
+ host = host
)
- }
- },
- )
- },
- {
- val errorWholeNumber =
- stringResource(Res.string.settings_error_whole_number)
- val errorPortRange =
- stringResource(Res.string.settings_error_port_range)
+ )
+ }
+ },
+ )
+ },
+ {
+ val errorWholeNumber =
+ stringResource(Res.string.settings_error_whole_number)
+ val errorPortRange =
+ stringResource(Res.string.settings_error_port_range)
- TextInputSettingCard(
- title = stringResource(Res.string.settings_jam_broker_port),
- value = broker.port.toString(),
- placeholder = "1883",
- normalize = { it.filter(Char::isDigit).take(5) },
- validate = { value ->
- val port = value.toIntOrNull()
- when {
- port == null -> errorWholeNumber
- port !in 1..65535 -> errorPortRange
- else -> null
- }
- },
- onValueSaved = { port ->
- viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
- },
- icon = {
- SettingsItemIcon(
- Iconsax.CustomServer,
- stringResource(Res.string.settings_jam_broker_port)
- )
- },
- )
- },
- {
- SwitchSettingCard(
- title = stringResource(Res.string.settings_jam_broker_tls),
- subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
- checked = broker.useTls,
- onCheckedChange = { tls ->
- viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
- },
- icon = {
- SettingsItemIcon(
- Iconsax.IconsaxWifiSquare,
- stringResource(Res.string.settings_jam_broker_tls)
- )
- },
- )
- },
- ),
- )
- settingsSectionHeader(Res.string.settings_jam_section_credentials)
- settingsSectionCard(
- items = listOf(
- {
- TextInputSettingCard(
- title = stringResource(Res.string.settings_jam_broker_username),
- value = broker.username.orEmpty(),
- placeholder = "anonymous",
- onValueSaved = { username ->
- viewModel.update {
- copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
- }
- },
- icon = {
- SettingsItemIcon(
- Iconsax.User,
- stringResource(Res.string.settings_jam_broker_username)
- )
- },
- )
- },
- {
- TextInputSettingCard(
- title = stringResource(Res.string.settings_jam_broker_password),
- value = broker.password.orEmpty(),
- placeholder = "••••••••",
- onValueSaved = { password ->
- viewModel.update {
- copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
- }
- },
- icon = {
- SettingsItemIcon(
- Iconsax.IconsaxEye,
- stringResource(Res.string.settings_jam_broker_password)
- )
- },
- )
- },
- ),
- )
- settingsSectionHeader(Res.string.settings_jam_section_identity)
- settingsSectionCard(
- items = listOf(
- {
- TextInputSettingCard(
- title = stringResource(Res.string.settings_jam_broker_client_id),
- subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
- value = broker.clientIdPrefix,
- placeholder = "spotube",
- onValueSaved = { prefix ->
- viewModel.update {
- copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
- }
- },
- icon = {
- SettingsItemIcon(
- Iconsax.IconsaxTag,
- stringResource(Res.string.settings_jam_broker_client_id)
- )
- },
- )
- },
- ),
- )
- settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
- settingsSectionCard(
- items = listOf(
- {
- val canTest = broker.host.isNotBlank() && !uiState.isTesting
- val resultMessage = uiState.testResult?.fold(
- onSuccess = { it },
- onFailure = { "Failed: ${it.message ?: "Connection failed"}" },
- )
+ TextInputSettingCard(
+ title = stringResource(Res.string.settings_jam_broker_port),
+ value = broker.port.toString(),
+ placeholder = "1883",
+ normalize = { it.filter(Char::isDigit).take(5) },
+ validate = { value ->
+ val port = value.toIntOrNull()
+ when {
+ port == null -> errorWholeNumber
+ port !in 1..65535 -> errorPortRange
+ else -> null
+ }
+ },
+ onValueSaved = { port ->
+ viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
+ },
+ icon = {
+ SettingsItemIcon(
+ Iconsax.CustomServer,
+ stringResource(Res.string.settings_jam_broker_port)
+ )
+ },
+ )
+ },
+ {
+ SwitchSettingCard(
+ title = stringResource(Res.string.settings_jam_broker_tls),
+ subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
+ checked = broker.useTls,
+ onCheckedChange = { tls ->
+ viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
+ },
+ icon = {
+ SettingsItemIcon(
+ Iconsax.IconsaxWifiSquare,
+ stringResource(Res.string.settings_jam_broker_tls)
+ )
+ },
+ )
+ },
+ ),
+ )
+ settingsSectionHeader(Res.string.settings_jam_section_credentials)
+ settingsSectionCard(
+ items = listOf(
+ {
+ TextInputSettingCard(
+ title = stringResource(Res.string.settings_jam_broker_username),
+ value = broker.username.orEmpty(),
+ placeholder = "anonymous",
+ onValueSaved = { username ->
+ viewModel.update {
+ copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
+ }
+ },
+ icon = {
+ SettingsItemIcon(
+ Iconsax.User,
+ stringResource(Res.string.settings_jam_broker_username)
+ )
+ },
+ )
+ },
+ {
+ TextInputSettingCard(
+ title = stringResource(Res.string.settings_jam_broker_password),
+ value = broker.password.orEmpty(),
+ placeholder = "••••••••",
+ onValueSaved = { password ->
+ viewModel.update {
+ copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
+ }
+ },
+ icon = {
+ SettingsItemIcon(
+ Iconsax.IconsaxEye,
+ stringResource(Res.string.settings_jam_broker_password)
+ )
+ },
+ )
+ },
+ ),
+ )
+ settingsSectionHeader(Res.string.settings_jam_section_identity)
+ settingsSectionCard(
+ items = listOf(
+ {
+ TextInputSettingCard(
+ title = stringResource(Res.string.settings_jam_broker_client_id),
+ subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
+ value = broker.clientIdPrefix,
+ placeholder = "spotube",
+ onValueSaved = { prefix ->
+ viewModel.update {
+ copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
+ }
+ },
+ icon = {
+ SettingsItemIcon(
+ Iconsax.IconsaxTag,
+ stringResource(Res.string.settings_jam_broker_client_id)
+ )
+ },
+ )
+ },
+ ),
+ )
+ settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
+ settingsSectionCard(
+ items = listOf(
+ {
+ val canTest = broker.host.isNotBlank() && !uiState.isTesting
+ val resultMessage = uiState.testResult?.fold(
+ onSuccess = { it },
+ onFailure = { "Failed: ${it.message ?: "Connection failed"}" },
+ )
- SettingCardItem(
- enabled = canTest,
- title = stringResource(Res.string.settings_jam_broker_test),
- subtitle = when {
- uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
- resultMessage != null -> resultMessage
- else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
- },
- icon = {
- SettingsItemIcon(
- Iconsax.IconsaxInformation,
- stringResource(Res.string.settings_jam_broker_test),
+ SettingCardItem(
+ enabled = canTest,
+ title = stringResource(Res.string.settings_jam_broker_test),
+ subtitle = when {
+ uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
+ resultMessage != null -> resultMessage
+ else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
+ },
+ icon = {
+ SettingsItemIcon(
+ Iconsax.IconsaxInformation,
+ stringResource(Res.string.settings_jam_broker_test),
+ )
+ },
+ trailingContent = {
+ PrimaryButton(
+ onClick = viewModel::testConnection,
+ enabled = canTest,
+ ) {
+ Text(
+ if (uiState.isTesting) {
+ stringResource(Res.string.settings_jam_broker_testing)
+ } else {
+ stringResource(Res.string.settings_jam_broker_test)
+ },
)
- },
- trailingContent = {
- PrimaryButton(
- onClick = viewModel::testConnection,
- enabled = canTest,
- ) {
- Text(
- if (uiState.isTesting) {
- stringResource(Res.string.settings_jam_broker_testing)
- } else {
- stringResource(Res.string.settings_jam_broker_test)
- },
- )
- }
- },
- onClick = viewModel::testConnection,
- )
- },
- ),
+ }
+ },
+ onClick = viewModel::testConnection,
+ )
+ },
+ ),
+ )
+ item {
+ Text(
+ text = stringResource(Res.string.settings_jam_broker_placeholder_note),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp),
)
- item {
- Text(
- text = stringResource(Res.string.settings_jam_broker_placeholder_note),
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp),
- )
- }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt
index f7119895..4430015c 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt
@@ -18,6 +18,7 @@
package dev.krtirtho.spotube.modules.settings
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -37,6 +38,7 @@ import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
import dev.krtirtho.spotube.core.navigation.NavigationCommands
+import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
@@ -52,17 +54,37 @@ import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
+/**
+ * Minimum width of the settings content area required to show the settings as an
+ * overlay dialog instead of a full-screen destination.
+ */
+private val SettingsDialogMinWidth = 720.dp
+
+/**
+ * On large screens settings is presented as a modal overlay so it does not sit next to
+ * the main navigation sidebar. On smaller screens it stays a full-screen destination.
+ */
+@Composable
+fun SettingsScreen(settingsViewModel: SettingsViewModel) {
+ val navigatorCommands: NavigationCommands = koinInject()
+
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ if (maxWidth >= SettingsDialogMinWidth) {
+ SettingsDialog(
+ settingsViewModel = settingsViewModel,
+ onDismiss = { navigatorCommands.pop(Routes.Settings) },
+ )
+ } else {
+ CompactSettingsScreen(settingsViewModel = settingsViewModel)
+ }
+ }
+}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SettingsScreen(settingsViewModel: SettingsViewModel) {
+private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) {
val navigatorCommands = koinInject()
val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
- val platformType = remember { getPlatform().type }
- val isDesktopPlatform = platformType == PlatformType.Windows ||
- platformType == PlatformType.Linux ||
- platformType == PlatformType.MacOS
-
val shellBottomInset = LocalAppShellBottomInset.current
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
val contentPadding = remember(shellBottomInset) {
@@ -79,66 +101,79 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
)
}
) { innerPadding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- ) {
- LazyColumn(
- modifier = Modifier
- .widthIn(max = 1280.dp)
- .align(Alignment.TopCenter),
- contentPadding = contentPadding,
- ) {
- pluginsSection(
- navigatorCommands = navigatorCommands,
- )
- if (settingsState != null) {
- languageRegionSection(
- settings = settingsState!!,
- settingsViewModel = settingsViewModel,
- )
- }
- if (settingsState != null)
- appearanceSection(
- settings = settingsState!!,
- settingsViewModel = settingsViewModel,
- )
- if (settingsState != null)
- playbackSection(
- settings = settingsState!!,
- settingsViewModel = settingsViewModel,
- navigatorCommands = navigatorCommands,
- requestLocalNetworkPermission = requestLocalNetworkPermission,
- )
- if (settingsState != null)
- jamSection(
- settings = settingsState!!,
- navigationCommands = navigatorCommands,
- )
- if (settingsState != null)
- cacheSection(
- settings = settingsState!!,
- settingsViewModel = settingsViewModel,
- )
- if (settingsState != null)
- downloadsSection(
- settings = settingsState!!,
- settingsViewModel = settingsViewModel,
- )
- if (isDesktopPlatform && settingsState != null) {
- desktopSection(
- settings = settingsState!!,
- settingsViewModel = settingsViewModel,
- )
- }
- if (settingsState != null)
- updatesSection(
- settings = settingsState!!,
- settingsViewModel = settingsViewModel,
- )
- }
+ CompactSettingsContent(
+ settings = settingsState,
+ settingsViewModel = settingsViewModel,
+ navigatorCommands = navigatorCommands,
+ requestLocalNetworkPermission = requestLocalNetworkPermission,
+ contentPadding = contentPadding,
+ modifier = Modifier.padding(innerPadding),
+ )
+ }
+}
+@Composable
+private fun CompactSettingsContent(
+ settings: UserSettings?,
+ settingsViewModel: SettingsViewModel,
+ navigatorCommands: NavigationCommands,
+ requestLocalNetworkPermission: () -> Unit,
+ contentPadding: PaddingValues,
+ modifier: Modifier = Modifier,
+) {
+ val platformType = remember { getPlatform().type }
+ val isDesktopPlatform = platformType == PlatformType.Windows ||
+ platformType == PlatformType.Linux ||
+ platformType == PlatformType.MacOS
+
+ Box(modifier = modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier
+ .widthIn(max = 1280.dp)
+ .align(Alignment.TopCenter),
+ contentPadding = contentPadding,
+ ) {
+ pluginsSection(
+ navigatorCommands = navigatorCommands,
+ )
+ if (settings != null) {
+ languageRegionSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ )
+ appearanceSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ )
+ playbackSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ navigatorCommands = navigatorCommands,
+ requestLocalNetworkPermission = requestLocalNetworkPermission,
+ )
+ jamSection(
+ settings = settings,
+ navigationCommands = navigatorCommands,
+ )
+ cacheSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ )
+ downloadsSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ )
+ if (isDesktopPlatform) {
+ desktopSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ )
+ }
+ updatesSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ )
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt
new file mode 100644
index 00000000..01e560cd
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt
@@ -0,0 +1,356 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.modules.settings
+
+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.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.VerticalDivider
+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.saveable.rememberSaveable
+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.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dev.krtirtho.spotube.PlatformType
+import dev.krtirtho.spotube.getPlatform
+import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
+import dev.krtirtho.spotube.core.navigation.NavigationCommands
+import dev.krtirtho.spotube.core.ui.base.GhostIconButton
+import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
+import dev.krtirtho.spotube.core.ui.component.SidebarItem
+import dev.krtirtho.spotube.modules.blacklist.BlacklistContent
+import dev.krtirtho.spotube.modules.plugin.PluginScreenContent
+import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
+import dev.krtirtho.spotube.modules.settings.sections.cacheSection
+import dev.krtirtho.spotube.modules.settings.sections.desktopSection
+import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
+import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
+import dev.krtirtho.spotube.modules.settings.sections.playbackSection
+import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle
+import dev.krtirtho.spotube.modules.settings.sections.updatesSection
+import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
+import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
+import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
+import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
+import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
+import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
+import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2
+import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
+import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare
+import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
+import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.koinInject
+import org.koin.compose.viewmodel.koinViewModel
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.settings_blacklist_title
+import spotube.composeapp.generated.resources.settings_screen_title
+import spotube.composeapp.generated.resources.settings_section_about
+import spotube.composeapp.generated.resources.settings_section_appearance
+import spotube.composeapp.generated.resources.settings_section_caching
+import spotube.composeapp.generated.resources.settings_section_desktop
+import spotube.composeapp.generated.resources.settings_section_downloads
+import spotube.composeapp.generated.resources.settings_section_jam
+import spotube.composeapp.generated.resources.settings_section_language_region
+import spotube.composeapp.generated.resources.settings_section_playback
+import spotube.composeapp.generated.resources.settings_section_plugins
+
+private val SettingsSidebarWidth = 236.dp
+
+/**
+ * The sections reachable from the large-screen settings sidebar, in display order.
+ *
+ * Each entry maps to either a list-based settings section or a standalone
+ * screen (Plugins, Blacklist, Group Jam) whose content is embedded in the tab.
+ */
+internal enum class SettingsTab(
+ val titleRes: StringResource,
+ val icon: ImageVector,
+) {
+ Plugins(Res.string.settings_section_plugins, Iconsax.IconsaxBoxAdd),
+ LanguageRegion(Res.string.settings_section_language_region, Iconsax.IconsaxLanguageSquare),
+ Appearance(Res.string.settings_section_appearance, Iconsax.IconsaxColorSwatch),
+ Playback(Res.string.settings_section_playback, Iconsax.IconsaxMusicPlay),
+ Blacklist(Res.string.settings_blacklist_title, Iconsax.IconsaxForbidden),
+ GroupJam(Res.string.settings_section_jam, Iconsax.IconsaxSoundTwotone),
+ Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload),
+ Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive),
+ Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
+ About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
+}
+
+/**
+ * Large-screen settings, presented as a modal overlay (dialog) over the whole app so it
+ * never sits next to the main navigation sidebar.
+ */
+@Composable
+internal fun SettingsDialog(
+ settingsViewModel: SettingsViewModel,
+ onDismiss: () -> Unit,
+) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ // The dialog is its own surface; the player/bottom bars behind it should not
+ // reserve any inset inside the dialog content.
+ CompositionLocalProvider(LocalAppShellBottomInset provides 0.dp) {
+ SettingsPane(
+ settingsViewModel = settingsViewModel,
+ onClose = onDismiss,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SettingsPane(
+ settingsViewModel: SettingsViewModel,
+ onClose: () -> Unit,
+) {
+ val navigatorCommands: NavigationCommands = koinInject()
+ val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
+ val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
+ val contentPadding = remember { PaddingValues(top = 16.dp, bottom = 24.dp) }
+ val dialogTheme = LocalBaseUITheme.current.dialog
+
+ val platformType = remember { getPlatform().type }
+ val isDesktopPlatform = platformType == PlatformType.Windows ||
+ platformType == PlatformType.Linux ||
+ platformType == PlatformType.MacOS
+
+ val tabs = remember(isDesktopPlatform) {
+ SettingsTab.entries.filter { it != SettingsTab.Desktop || isDesktopPlatform }
+ }
+ var selectedIndex by rememberSaveable { mutableStateOf(0) }
+ val selectedTab = tabs.getOrElse(selectedIndex) { tabs.first() }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ modifier = Modifier
+ .widthIn(max = 1200.dp)
+ .heightIn(max = 860.dp)
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .shadow(
+ elevation = dialogTheme.shadow.elevation,
+ shape = dialogTheme.shape,
+ 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,
+ ),
+ ) {
+ SettingsSidebar(
+ tabs = tabs,
+ selectedTab = selectedTab,
+ onSelect = { selectedIndex = tabs.indexOf(it) },
+ )
+ VerticalDivider(
+ modifier = Modifier.fillMaxHeight(),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ thickness = 1.dp,
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(modifier = Modifier.weight(1f)) {
+ SettingsPageTitle(selectedTab.titleRes)
+ }
+ GhostIconButton(
+ onClick = onClose,
+ modifier = Modifier.padding(end = 8.dp),
+ ) {
+ Icon(
+ imageVector = Iconsax.IconsaxCloseSquare,
+ contentDescription = "Close",
+ )
+ }
+ }
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ ) {
+ SettingsTabContent(
+ tab = selectedTab,
+ settings = settingsState,
+ settingsViewModel = settingsViewModel,
+ navigatorCommands = navigatorCommands,
+ requestLocalNetworkPermission = requestLocalNetworkPermission,
+ contentPadding = contentPadding,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SettingsSidebar(
+ tabs: List,
+ selectedTab: SettingsTab,
+ onSelect: (SettingsTab) -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier
+ .width(SettingsSidebarWidth)
+ .fillMaxHeight()
+ .padding(horizontal = 8.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ item {
+ Text(
+ stringResource(Res.string.settings_screen_title),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 16.dp)
+ )
+ }
+ items(tabs, key = { it.name }) { tab ->
+ SidebarItem(
+ label = stringResource(tab.titleRes),
+ icon = tab.icon,
+ selected = tab == selectedTab,
+ onClick = { onSelect(tab) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SettingsTabContent(
+ tab: SettingsTab,
+ settings: UserSettings?,
+ settingsViewModel: SettingsViewModel,
+ navigatorCommands: NavigationCommands,
+ requestLocalNetworkPermission: () -> Unit,
+ contentPadding: PaddingValues,
+) {
+ when (tab) {
+ SettingsTab.Plugins -> {
+ PluginScreenContent(viewModel = koinViewModel())
+ }
+
+ SettingsTab.Blacklist -> {
+ BlacklistContent(viewModel = koinViewModel())
+ }
+
+ SettingsTab.GroupJam -> {
+ JamSettingsContent(viewModel = koinViewModel())
+ }
+
+ else -> {
+ if (settings == null) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier
+ .widthIn(max = 1280.dp)
+ .align(Alignment.TopCenter),
+ contentPadding = contentPadding,
+ ) {
+ when (tab) {
+ SettingsTab.LanguageRegion ->
+ languageRegionSection(settings, settingsViewModel, includeHeader = false)
+
+ SettingsTab.Appearance ->
+ appearanceSection(settings, settingsViewModel, includeHeader = false)
+
+ SettingsTab.Playback ->
+ playbackSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ navigatorCommands = navigatorCommands,
+ requestLocalNetworkPermission = requestLocalNetworkPermission,
+ includeBlacklist = false,
+ includeHeader = false,
+ )
+
+ SettingsTab.Caching ->
+ cacheSection(settings, settingsViewModel, includeHeader = false)
+
+ SettingsTab.Downloads ->
+ downloadsSection(settings, settingsViewModel, includeHeader = false)
+
+ SettingsTab.Desktop ->
+ desktopSection(settings, settingsViewModel, includeHeader = false)
+
+ SettingsTab.About ->
+ updatesSection(settings, settingsViewModel, includeHeader = false)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt
index 0838fc75..bc178108 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt
@@ -67,8 +67,11 @@ import spotube.composeapp.generated.resources.*
internal fun LazyListScope.appearanceSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
+ includeHeader: Boolean = true,
) {
- settingsSectionHeader(Res.string.settings_section_appearance)
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_appearance)
+ }
settingsSectionCard(
items = listOf(
{
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt
index 4ff417cc..c43fc60a 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt
@@ -42,8 +42,11 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.cacheSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
+ includeHeader: Boolean = true,
) {
- settingsSectionHeader(Res.string.settings_section_caching)
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_caching)
+ }
settingsSectionCard(
items = listOf(
{
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt
index 54de88c7..3095f488 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt
@@ -30,8 +30,11 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.desktopSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
+ includeHeader: Boolean = true,
) {
- settingsSectionHeader(Res.string.settings_section_desktop)
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_desktop)
+ }
settingsSectionCard(
items = listOf(
{
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt
index 5750a811..4acd6832 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt
@@ -79,6 +79,7 @@ import spotube.composeapp.generated.resources.settings_subtitle_current
internal fun LazyListScope.downloadsSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
+ includeHeader: Boolean = true,
) {
val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets)
val downloadQualities = availableAudioQualities(
@@ -86,7 +87,9 @@ internal fun LazyListScope.downloadsSection(
current = settings.downloadMusicQuality,
)
- settingsSectionHeader(Res.string.settings_section_downloads)
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_downloads)
+ }
settingsSectionCard(
items = listOf(
{
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt
index e0e9545d..046a28df 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt
@@ -35,8 +35,11 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.languageRegionSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
+ includeHeader: Boolean = true,
) {
- settingsSectionHeader(Res.string.settings_section_language_region)
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_language_region)
+ }
settingsSectionCard(
items = listOf(
{
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt
index 52d0a190..0b4f515b 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt
@@ -20,6 +20,7 @@ package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight
import compose.icons.feathericons.Cast
@@ -51,6 +52,8 @@ internal fun LazyListScope.playbackSection(
settingsViewModel: SettingsViewModel,
navigatorCommands: NavigationCommands,
requestLocalNetworkPermission: () -> Unit,
+ includeBlacklist: Boolean = true,
+ includeHeader: Boolean = true,
) {
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
val streamingQualities = availableAudioQualities(
@@ -58,163 +61,162 @@ internal fun LazyListScope.playbackSection(
current = settings.streamingMusicQuality,
)
- settingsSectionHeader(Res.string.settings_section_playback)
- settingsSectionCard(
- items = listOf(
- {
- SelectionSettingCard(
- title = stringResource(Res.string.settings_streaming_format_title),
- subtitle = stringResource(
- Res.string.settings_streaming_format_subtitle_current,
- settings.streamingMusicFormat.displayLabel()
- ),
- icon = {
- SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title))
- },
- selectedOption = settings.streamingMusicFormat,
- options = streamingFormats,
- optionLabel = { it.displayLabel() },
- onOptionSelected = { format ->
- settingsViewModel.updateSettings {
- copy(
- streamingMusicFormat = format,
- streamingMusicQuality = format.resolveQuality(streamingMusicQuality),
- )
- }
- }
- )
- },
- {
- SelectionSettingCard(
- title = stringResource(Res.string.settings_streaming_quality_title),
- subtitle = stringResource(
- Res.string.settings_subtitle_current,
- settings.streamingMusicQuality.displayLabel()
- ),
- icon = {
- SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title))
- },
- selectedOption = settings.streamingMusicQuality,
- options = streamingQualities,
- optionLabel = { it.displayLabel() },
- onOptionSelected = { quality ->
- settingsViewModel.updateSettings {
- copy(streamingMusicQuality = quality)
- }
- }
- )
- },
- {
- SwitchSettingCard(
- title = stringResource(Res.string.settings_enable_endless_playback_title),
- subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
- icon = {
- SettingsItemIcon(
- Iconsax.IconsaxRepeatArrow,
- stringResource(Res.string.settings_enable_endless_playback_title)
+ val cards: List<@Composable () -> Unit> = buildList {
+ add {
+ SelectionSettingCard(
+ title = stringResource(Res.string.settings_streaming_format_title),
+ subtitle = stringResource(
+ Res.string.settings_streaming_format_subtitle_current,
+ settings.streamingMusicFormat.displayLabel()
+ ),
+ icon = {
+ SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title))
+ },
+ selectedOption = settings.streamingMusicFormat,
+ options = streamingFormats,
+ optionLabel = { it.displayLabel() },
+ onOptionSelected = { format ->
+ settingsViewModel.updateSettings {
+ copy(
+ streamingMusicFormat = format,
+ streamingMusicQuality = format.resolveQuality(streamingMusicQuality),
)
- },
- checked = settings.enableEndlessPlayback,
- onCheckedChange = { enabled ->
- settingsViewModel.updateSettings {
- copy(enableEndlessPlayback = enabled)
- }
}
- )
- },
- {
- SwitchSettingCard(
- title = stringResource(Res.string.settings_enable_connect_title),
- subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
- icon = {
- SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
- },
- checked = settings.enableConnect,
- onCheckedChange = { enabled ->
- settingsViewModel.updateSettings {
- copy(enableConnect = enabled)
- }
+ }
+ )
+ }
+ add {
+ SelectionSettingCard(
+ title = stringResource(Res.string.settings_streaming_quality_title),
+ subtitle = stringResource(
+ Res.string.settings_subtitle_current,
+ settings.streamingMusicQuality.displayLabel()
+ ),
+ icon = {
+ SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title))
+ },
+ selectedOption = settings.streamingMusicQuality,
+ options = streamingQualities,
+ optionLabel = { it.displayLabel() },
+ onOptionSelected = { quality ->
+ settingsViewModel.updateSettings {
+ copy(streamingMusicQuality = quality)
}
- )
- },
- {
- SwitchSettingCard(
- title = stringResource(Res.string.settings_allow_remote_control_title),
- subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
- icon = {
- SettingsItemIcon(
- Iconsax.IconsaxMirroringScreen,
- stringResource(Res.string.settings_allow_remote_control_title)
- )
- },
- checked = settings.allowRemoteControl,
- onCheckedChange = { enabled ->
- settingsViewModel.updateSettings {
- copy(allowRemoteControl = enabled)
- }
- // Request the local network permission when enabling remote control
- // so that DNS-SD registration can succeed on Android 16+
- if (enabled) {
- requestLocalNetworkPermission()
- }
+ }
+ )
+ }
+ add {
+ SwitchSettingCard(
+ title = stringResource(Res.string.settings_enable_endless_playback_title),
+ subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
+ icon = {
+ SettingsItemIcon(
+ Iconsax.IconsaxRepeatArrow,
+ stringResource(Res.string.settings_enable_endless_playback_title)
+ )
+ },
+ checked = settings.enableEndlessPlayback,
+ onCheckedChange = { enabled ->
+ settingsViewModel.updateSettings {
+ copy(enableEndlessPlayback = enabled)
}
- )
- },
- {
- TextInputSettingCard(
- title = stringResource(Res.string.settings_remote_device_name_title),
- subtitle = stringResource(
- Res.string.settings_remote_device_name_subtitle,
- settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
- ),
- icon = {
- SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
- },
- value = settings.remoteControlDeviceName,
- dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
- placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
- normalize = { it.trim() },
- validate = { _ -> null },
- onValueSaved = { value ->
- settingsViewModel.updateSettings {
- copy(remoteControlDeviceName = value)
- }
+ }
+ )
+ }
+ add {
+ SwitchSettingCard(
+ title = stringResource(Res.string.settings_enable_connect_title),
+ subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
+ icon = {
+ SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
+ },
+ checked = settings.enableConnect,
+ onCheckedChange = { enabled ->
+ settingsViewModel.updateSettings {
+ copy(enableConnect = enabled)
}
- )
- },
- {
- val error_whole_number = stringResource(Res.string.settings_error_whole_number)
- val error_port_range = stringResource(Res.string.settings_error_port_range)
+ }
+ )
+ }
+ add {
+ SwitchSettingCard(
+ title = stringResource(Res.string.settings_allow_remote_control_title),
+ subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
+ icon = {
+ SettingsItemIcon(
+ Iconsax.IconsaxMirroringScreen,
+ stringResource(Res.string.settings_allow_remote_control_title)
+ )
+ },
+ checked = settings.allowRemoteControl,
+ onCheckedChange = { enabled ->
+ settingsViewModel.updateSettings {
+ copy(allowRemoteControl = enabled)
+ }
+ // Request the local network permission when enabling remote control
+ // so that DNS-SD registration can succeed on Android 16+
+ if (enabled) {
+ requestLocalNetworkPermission()
+ }
+ }
+ )
+ }
+ add {
+ TextInputSettingCard(
+ title = stringResource(Res.string.settings_remote_device_name_title),
+ subtitle = stringResource(
+ Res.string.settings_remote_device_name_subtitle,
+ settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
+ ),
+ icon = {
+ SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
+ },
+ value = settings.remoteControlDeviceName,
+ dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
+ placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
+ normalize = { it.trim() },
+ validate = { _ -> null },
+ onValueSaved = { value ->
+ settingsViewModel.updateSettings {
+ copy(remoteControlDeviceName = value)
+ }
+ }
+ )
+ }
+ add {
+ val error_whole_number = stringResource(Res.string.settings_error_whole_number)
+ val error_port_range = stringResource(Res.string.settings_error_port_range)
- TextInputSettingCard(
- title = stringResource(Res.string.settings_playback_port_title),
- subtitle = stringResource(
- Res.string.settings_playback_port_subtitle_current,
- settings.playbackProxyServerPort
- ),
- icon = {
- SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title))
- },
- value = settings.playbackProxyServerPort.toString(),
- dialogDescription = stringResource(Res.string.settings_playback_port_description),
- placeholder = stringResource(Res.string.settings_playback_port_placeholder),
- normalize = { it.trim() },
- validate = { value ->
- val port = value.toIntOrNull()
- when {
- port == null -> error_whole_number
- port !in 1..65535 -> error_port_range
- else -> null
- }
- },
- onValueSaved = { value ->
- settingsViewModel.updateSettings {
- copy(playbackProxyServerPort = value.toInt())
- }
+ TextInputSettingCard(
+ title = stringResource(Res.string.settings_playback_port_title),
+ subtitle = stringResource(
+ Res.string.settings_playback_port_subtitle_current,
+ settings.playbackProxyServerPort
+ ),
+ icon = {
+ SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title))
+ },
+ value = settings.playbackProxyServerPort.toString(),
+ dialogDescription = stringResource(Res.string.settings_playback_port_description),
+ placeholder = stringResource(Res.string.settings_playback_port_placeholder),
+ normalize = { it.trim() },
+ validate = { value ->
+ val port = value.toIntOrNull()
+ when {
+ port == null -> error_whole_number
+ port !in 1..65535 -> error_port_range
+ else -> null
}
- )
- },
- {
+ },
+ onValueSaved = { value ->
+ settingsViewModel.updateSettings {
+ copy(playbackProxyServerPort = value.toInt())
+ }
+ }
+ )
+ }
+ if (includeBlacklist) {
+ add {
SettingCardItem(
title = stringResource(Res.string.settings_blacklist_title),
subtitle = stringResource(Res.string.settings_blacklist_subtitle),
@@ -235,8 +237,12 @@ internal fun LazyListScope.playbackSection(
navigatorCommands.navigateTo(Routes.Blacklist)
},
)
- },
- )
- )
-}
+ }
+ }
+ }
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_playback)
+ }
+ settingsSectionCard(items = cards)
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt
index 0981d0e7..f81cf926 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt
@@ -32,8 +32,11 @@ import spotube.composeapp.generated.resources.settings_updates_auto_check_title
internal fun LazyListScope.updatesSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
+ includeHeader: Boolean = true,
) {
- settingsSectionHeader(Res.string.settings_section_updates)
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_updates)
+ }
settingsSectionCard(
items = listOf {
SwitchSettingCard(
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt
index 4a897cf8..b2e99aa7 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt
@@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
@@ -103,6 +104,23 @@ internal fun LazyListScope.settingsSectionHeader(title: StringResource) {
}
}
+/**
+ * A page-level title for a settings destination (used by the large-screen sidebar
+ * layout, where each section is its own page rather than one entry in a long list).
+ */
+@Composable
+internal fun SettingsPageTitle(title: StringResource) {
+ Text(
+ text = stringResource(title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ )
+}
+
@Composable
internal fun SettingsItemIcon(
imageVector: ImageVector,
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt
index 36ede564..ce632b19 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt
@@ -24,23 +24,18 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
-import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -51,7 +46,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -64,11 +58,8 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
-import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
-import dev.krtirtho.spotube.core.ui.base.OutlineButton
-import dev.krtirtho.spotube.core.ui.base.SecondaryButton
-import dev.krtirtho.spotube.core.ui.base.copyPadding
-import dev.krtirtho.spotube.core.ui.base.copyShape
+import dev.krtirtho.spotube.core.ui.component.SidebarItem
+import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.modules.library.LibraryState
@@ -150,20 +141,12 @@ fun AppSidebar(
items(sidebarTabs.size) { index ->
when (val tab = sidebarTabs[index]) {
is NavigationItem.Group<*> -> {
- androidx.compose.animation.AnimatedVisibility(visible = expanded) {
- Text(
- text = tab.title.uppercase(),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 8.dp)
- )
- }
+ SidebarSectionLabel(text = tab.title, expanded = expanded)
tab.items.forEach {
val selected = navigationState.topLevelRoute == it.route
SidebarItem(
label = it.title,
- activeIcon = it.icon,
+ icon = it.icon,
onClick = {
if (it.route == Routes.Library) {
libraryState.currentTab.value =
@@ -173,8 +156,17 @@ fun AppSidebar(
},
selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected,
expanded = expanded,
- showDownloadBadge = it.route == Routes.Library
- && it.data == LibraryTab.Downloads
+ badge = if (it.route == Routes.Library
+ && it.data == LibraryTab.Downloads
+ ) {
+ {
+ DownloadBadgeIndicator(
+ modifier = Modifier.align(Alignment.TopEnd)
+ )
+ }
+ } else {
+ null
+ },
)
}
@@ -184,7 +176,7 @@ fun AppSidebar(
val selected = navigationState.topLevelRoute == tab.route
SidebarItem(
label = tab.title,
- activeIcon = tab.icon,
+ icon = tab.icon,
onClick = {
navigator.navigate(tab.route)
},
@@ -206,7 +198,7 @@ fun AppSidebar(
HorizontalDivider()
SidebarItem(
label = "Settings",
- activeIcon = Iconsax.IconsaxSetting2,
+ icon = Iconsax.IconsaxSetting2,
onClick = {
navigator.navigate(Routes.Settings)
},
@@ -216,75 +208,3 @@ fun AppSidebar(
Spacer(modifier = Modifier.height(120.dp))
}
}
-
-@Composable
-fun SidebarItem(
- label: String,
- activeIcon: ImageVector,
- selected: Boolean,
- expanded: Boolean,
- onClick: () -> Unit,
- showDownloadBadge: Boolean = false,
-) {
- val itemContent: @Composable RowScope.() -> Unit = {
- Box(modifier = Modifier.size(16.dp)) {
- Icon(
- imageVector = activeIcon,
- contentDescription = label,
- tint = if (selected) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- },
- )
- if (showDownloadBadge) {
- DownloadBadgeIndicator(
- modifier = Modifier.align(Alignment.TopEnd)
- )
- }
- }
- AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
- Text(
- text = label,
- maxLines = 1,
- softWrap = false,
- style = LocalTextStyle.current.copy(
- fontSize = MaterialTheme.typography.labelMedium.fontSize,
- fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
- color = if (selected) {
- MaterialTheme.colorScheme.onSecondaryContainer
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- }
- )
- )
- }
- Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
- }
-
- val buttonModifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 7.dp, vertical = 2.dp)
- val contentPadding = PaddingValues(horizontal = 5.dp)
-
- if (selected) {
- SecondaryButton(
- onClick = onClick,
- modifier = buttonModifier,
- theme = LocalBaseUITheme.current.buttons.secondary
- .copyPadding(contentPadding)
- .copyShape(RoundedCornerShape(5.dp)),
- content = itemContent,
- )
- } else {
- OutlineButton(
- onClick = onClick,
- modifier = buttonModifier,
- theme = LocalBaseUITheme.current.buttons.outline
- .copyPadding(contentPadding)
- .copyShape(RoundedCornerShape(5.dp)),
- hoverOnly = true,
- content = itemContent,
- )
- }
-}
\ No newline at end of file