feat(settings): add 'About' section and improve header visibility in settings sections

This commit is contained in:
Kingkor Roy Tirtho 2026-10-07 14:07:09 +06:00
parent 5c0652b395
commit fa9ad9981c
33 changed files with 1826 additions and 824 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<g id="Alapkör">
<circle id="XMLID_23_" fill="#CD201F" cx="50" cy="50" r="50"/>
</g>
<g id="Elemek">
<path id="XMLID_19_" fill="#FFFFFF" d="M47,28.2c-9-5.3-15.3-9-15.3-9v61.7c0,0,30.4-18,52.3-30.9C72.1,43,57.7,34.5,47,28.2z"/>
</g>
<g id="Fedő">
<path id="XMLID_5_" fill="#CD201F" d="M48.4,40.1c-4.1-2.4-7-4.1-7-4.1V64c0,0,13.9-8.2,23.8-14C59.8,46.8,53.3,42.9,48.4,40.1z"/>
<rect id="XMLID_4_" x="41.4" y="55.6" fill="#CD201F" width="6.2" height="21"/>
</g>
<g id="Vonalak">
</g>
</svg>

After

Width:  |  Height:  |  Size: 787 B

View File

@ -26,6 +26,7 @@
<string name="settings_section_caching">Caching</string>
<string name="settings_section_desktop">Desktop</string>
<string name="settings_section_updates">Updates</string>
<string name="settings_section_about">About</string>
<string name="settings_action_close">Close</string>
<string name="settings_action_save">Save</string>

View File

@ -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,6 +158,7 @@ fun DropdownMenuItem(
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
@ -163,10 +167,19 @@ fun DropdownMenuItem(
} else {
menuTheme.itemForeground.copy(alpha = 0.38f)
},
modifier = Modifier.weight(1f),
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,
)
}
}
}
}

View File

@ -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,14 +265,11 @@ private fun AdaptiveBottomSheetContent(
)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (item.icon != null) {
Icon(
when {
item.leadingContent != null -> item.leadingContent.invoke()
item.icon != null -> Icon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
@ -278,16 +279,18 @@ private fun AdaptiveBottomSheetContent(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
} else if (item.selected) {
Icon(
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))
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",

View File

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

View File

@ -67,6 +67,30 @@ import org.koin.compose.viewmodel.koinViewModel
@Composable
fun BlacklistScreen() {
val viewModel = koinViewModel<BlacklistViewModel>()
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,18 +126,9 @@ fun BlacklistScreen() {
val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty()
val hasActiveFilter = normalizedQuery.isNotBlank()
Scaffold(
topBar = {
ApplicationMainBar(
backButton = true,
title = { Text("Blacklist") },
)
},
) { innerPadding ->
Column(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@ -192,7 +207,6 @@ fun BlacklistScreen() {
}
}
}
}
}
@Composable

View File

@ -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
}

View File

@ -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<String>()
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,17 +713,12 @@ 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(),
PluginLogo(
plugin = plugin,
logoPath = logoPath,
contentDescription = plugin.name,
modifier = Modifier.fillMaxSize()
)
} else {
modifier = Modifier.fillMaxSize(),
fallback = {
Box(contentAlignment = Alignment.Center) {
Icon(
Iconsax.IconsaxBox,
@ -740,7 +727,8 @@ private fun PluginInfoDialog(
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)
}
}

View File

@ -63,6 +63,7 @@ data class AbilitySelection(
val ability: PluginAbility,
val plugins: List<PluginEntry>,
val selectedPlugin: PluginEntry?,
val pluginLogoPaths: Map<String, Path?> = 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,

View File

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

View File

@ -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,17 +88,12 @@ 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(),
PluginLogo(
plugin = plugin,
logoPath = logoPath,
contentDescription = plugin.name,
modifier = Modifier.fillMaxSize()
)
} else {
modifier = Modifier.fillMaxSize(),
fallback = {
Box(contentAlignment = Alignment.Center) {
Icon(
Iconsax.IconsaxBox,
@ -115,7 +102,8 @@ internal fun PluginCard(
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)
}
}

View File

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

View File

@ -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(),
PluginLogo(
plugin = pluginInfo,
logoPath = logoPath,
contentDescription = pluginInfo.name,
modifier = Modifier.fillMaxSize()
)
} else {
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(

View File

@ -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,10 +66,32 @@ fun JamSettingsScreen(
)
},
) { innerPadding ->
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()
.padding(innerPadding),
modifier = modifier
.fillMaxSize(),
) {
LazyColumn(
modifier = Modifier
@ -270,5 +285,4 @@ fun JamSettingsScreen(
}
}
}
}
}

View File

@ -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<NavigationCommands>()
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,11 +101,32 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
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)
@ -93,52 +136,44 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
pluginsSection(
navigatorCommands = navigatorCommands,
)
if (settingsState != null) {
if (settings != null) {
languageRegionSection(
settings = settingsState!!,
settings = settings,
settingsViewModel = settingsViewModel,
)
}
if (settingsState != null)
appearanceSection(
settings = settingsState!!,
settings = settings,
settingsViewModel = settingsViewModel,
)
if (settingsState != null)
playbackSection(
settings = settingsState!!,
settings = settings,
settingsViewModel = settingsViewModel,
navigatorCommands = navigatorCommands,
requestLocalNetworkPermission = requestLocalNetworkPermission,
)
if (settingsState != null)
jamSection(
settings = settingsState!!,
settings = settings,
navigationCommands = navigatorCommands,
)
if (settingsState != null)
cacheSection(
settings = settingsState!!,
settings = settings,
settingsViewModel = settingsViewModel,
)
if (settingsState != null)
downloadsSection(
settings = settingsState!!,
settings = settings,
settingsViewModel = settingsViewModel,
)
if (isDesktopPlatform && settingsState != null) {
if (isDesktopPlatform) {
desktopSection(
settings = settingsState!!,
settings = settings,
settingsViewModel = settingsViewModel,
)
}
if (settingsState != null)
updatesSection(
settings = settingsState!!,
settings = settings,
settingsViewModel = settingsViewModel,
)
}
}
}
}

View File

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

View File

@ -67,8 +67,11 @@ import spotube.composeapp.generated.resources.*
internal fun LazyListScope.appearanceSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true,
) {
if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_appearance)
}
settingsSectionCard(
items = listOf(
{

View File

@ -42,8 +42,11 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.cacheSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true,
) {
if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_caching)
}
settingsSectionCard(
items = listOf(
{

View File

@ -30,8 +30,11 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.desktopSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true,
) {
if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_desktop)
}
settingsSectionCard(
items = listOf(
{

View File

@ -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,
)
if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_downloads)
}
settingsSectionCard(
items = listOf(
{

View File

@ -35,8 +35,11 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.languageRegionSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true,
) {
if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_language_region)
}
settingsSectionCard(
items = listOf(
{

View File

@ -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,10 +61,8 @@ internal fun LazyListScope.playbackSection(
current = settings.streamingMusicQuality,
)
settingsSectionHeader(Res.string.settings_section_playback)
settingsSectionCard(
items = listOf(
{
val cards: List<@Composable () -> Unit> = buildList {
add {
SelectionSettingCard(
title = stringResource(Res.string.settings_streaming_format_title),
subtitle = stringResource(
@ -83,8 +84,8 @@ internal fun LazyListScope.playbackSection(
}
}
)
},
{
}
add {
SelectionSettingCard(
title = stringResource(Res.string.settings_streaming_quality_title),
subtitle = stringResource(
@ -103,8 +104,8 @@ internal fun LazyListScope.playbackSection(
}
}
)
},
{
}
add {
SwitchSettingCard(
title = stringResource(Res.string.settings_enable_endless_playback_title),
subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
@ -121,8 +122,8 @@ internal fun LazyListScope.playbackSection(
}
}
)
},
{
}
add {
SwitchSettingCard(
title = stringResource(Res.string.settings_enable_connect_title),
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
@ -136,8 +137,8 @@ internal fun LazyListScope.playbackSection(
}
}
)
},
{
}
add {
SwitchSettingCard(
title = stringResource(Res.string.settings_allow_remote_control_title),
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
@ -159,8 +160,8 @@ internal fun LazyListScope.playbackSection(
}
}
)
},
{
}
add {
TextInputSettingCard(
title = stringResource(Res.string.settings_remote_device_name_title),
subtitle = stringResource(
@ -181,8 +182,8 @@ internal fun LazyListScope.playbackSection(
}
}
)
},
{
}
add {
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
val error_port_range = stringResource(Res.string.settings_error_port_range)
@ -213,8 +214,9 @@ internal fun LazyListScope.playbackSection(
}
}
)
},
{
}
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)
}

View File

@ -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,
) {
if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_updates)
}
settingsSectionCard(
items = listOf {
SwitchSettingCard(

View File

@ -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,

View File

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