Compare commits

...

3 Commits

60 changed files with 3262 additions and 1353 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_caching">Caching</string>
<string name="settings_section_desktop">Desktop</string> <string name="settings_section_desktop">Desktop</string>
<string name="settings_section_updates">Updates</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_close">Close</string>
<string name="settings_action_save">Save</string> <string name="settings_action_save">Save</string>
@ -43,6 +44,14 @@
<string name="settings_theme_dark">Dark</string> <string name="settings_theme_dark">Dark</string>
<string name="settings_theme_system">System</string> <string name="settings_theme_system">System</string>
<string name="settings_surface_theme_title">Surface</string>
<string name="settings_surface_theme_subtitle_current">Background material of the app. Current: %1$s</string>
<string name="settings_surface_watery">Watery</string>
<string name="settings_surface_icy">Icy</string>
<string name="settings_surface_chrome">Chrome</string>
<string name="settings_surface_acrylic">Acrylic</string>
<string name="settings_surface_glass">Glass</string>
<string name="settings_accent_title">Accent color</string> <string name="settings_accent_title">Accent color</string>
<string name="settings_accent_subtitle_current">Current palette: %1$s</string> <string name="settings_accent_subtitle_current">Current palette: %1$s</string>
<string name="settings_accent_dialog_title">Accent color</string> <string name="settings_accent_dialog_title">Accent color</string>

View File

@ -35,6 +35,7 @@ import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
@ -156,8 +157,11 @@ fun App(
} }
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
val baseUITheme = rememberBaseUITheme() val baseUITheme = rememberBaseUITheme(surfaceTheme = userSettings.surfaceTheme)
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) { CompositionLocalProvider(
LocalBaseUITheme provides baseUITheme,
LocalBaseUIColors provides baseUITheme.colors,
) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
val currentRoute = val currentRoute =
navigationState.backStacks[navigationState.topLevelRoute]?.last() navigationState.backStacks[navigationState.topLevelRoute]?.last()

View File

@ -0,0 +1,302 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
/**
* The app's own colour system, independent of [MaterialTheme].
*
* Each [SurfaceStyle] is a complete visual theme with its own palette (backgrounds,
* surfaces, text and outlines) rather than Material's role colours. The user's chosen
* accent is threaded through as `primary`, but everything else comes from the surface
* theme, so the app never has to rely on Material's colour scheme.
*/
@Immutable
data class BaseUIColorScheme(
val surfaceTheme: SurfaceStyle,
val isLight: Boolean,
/** Full-bleed page background for the current surface theme. */
val background: Brush,
val surface: Color,
val surfaceVariant: Color,
val surfaceContainer: Color,
val surfaceContainerHigh: Color,
val surfaceContainerHighest: Color,
val onBackground: Color,
val onSurface: Color,
val onSurfaceVariant: Color,
val primary: Color,
val onPrimary: Color,
val primaryContainer: Color,
val onPrimaryContainer: Color,
val secondaryContainer: Color,
val onSecondaryContainer: Color,
val outline: Color,
val outlineVariant: Color,
val error: Color,
val onError: Color,
val scrim: Color,
)
val LocalBaseUIColors = staticCompositionLocalOf<BaseUIColorScheme> {
error("No BaseUIColorScheme provided. Wrap your UI in a BaseUITheme provider.")
}
/**
* Builds the [BaseUIColorScheme] for the selected [surfaceTheme] and accent.
*
* Light/dark is read from the surrounding [MaterialTheme] (which the app already drives
* from the user's theme setting), and the accent comes from the user's chosen accent
* colour. Everything else is derived from the surface theme palette.
*/
@Composable
fun rememberBaseUIColorScheme(
surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
accentColor: Color? = null,
): BaseUIColorScheme {
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
val accent = accentColor ?: MaterialTheme.colorScheme.primary
return remember(surfaceTheme, isLight, accent) {
baseUIColorScheme(surfaceTheme, isLight, accent)
}
}
fun baseUIColorScheme(
surfaceTheme: SurfaceStyle,
isLight: Boolean,
accent: Color,
): BaseUIColorScheme {
val palette = surfacePalette(surfaceTheme, isLight)
val onPrimary = if (accent.luminance() > 0.5f) Color(0xFF10161C) else Color.White
val primaryContainer = lerp(palette.surface, accent, if (isLight) 0.16f else 0.32f)
val onPrimaryContainer = if (isLight) {
lerp(accent, Color.Black, 0.55f)
} else {
lerp(accent, Color.White, 0.7f)
}
val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f)
return BaseUIColorScheme(
surfaceTheme = surfaceTheme,
isLight = isLight,
background = Brush.verticalGradient(
listOf(palette.backgroundTop, palette.backgroundBottom),
),
surface = palette.surface,
surfaceVariant = palette.surfaceVariant,
surfaceContainer = palette.surfaceContainer,
surfaceContainerHigh = palette.surfaceContainerHigh,
surfaceContainerHighest = palette.surfaceContainerHighest,
onBackground = palette.onBackground,
onSurface = palette.onSurface,
onSurfaceVariant = palette.onSurfaceVariant,
primary = accent,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondaryContainer = secondaryContainer,
onSecondaryContainer = palette.onSurface,
outline = palette.outline,
outlineVariant = palette.outlineVariant,
error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373),
onError = Color.White,
scrim = Color.Black.copy(alpha = 0.42f),
)
}
private class SurfacePalette(
val backgroundTop: Color,
val backgroundBottom: Color,
val surface: Color,
val surfaceVariant: Color,
val surfaceContainer: Color,
val surfaceContainerHigh: Color,
val surfaceContainerHighest: Color,
val onBackground: Color,
val onSurface: Color,
val onSurfaceVariant: Color,
val outline: Color,
val outlineVariant: Color,
)
private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette {
return if (isLight) {
when (style) {
SurfaceStyle.Glass -> SurfacePalette(
backgroundTop = Color(0xFFEAF0F7),
backgroundBottom = Color(0xFFD8E3F1),
surface = Color(0xFFF8FBFF),
surfaceVariant = Color(0xFFE4ECF5),
surfaceContainer = Color(0xFFEEF3F9),
surfaceContainerHigh = Color(0xFFF3F7FC),
surfaceContainerHighest = Color(0xFFE1E9F3),
onBackground = Color(0xFF1B2733),
onSurface = Color(0xFF1B2733),
onSurfaceVariant = Color(0xFF56677A),
outline = Color(0xFFA9B8C8),
outlineVariant = Color(0xFFD3DDE8),
)
SurfaceStyle.Acrylic -> SurfacePalette(
backgroundTop = Color(0xFFE6EAF0),
backgroundBottom = Color(0xFFD6DCE5),
surface = Color(0xFFF4F7FA),
surfaceVariant = Color(0xFFE1E7EE),
surfaceContainer = Color(0xFFEAEEF4),
surfaceContainerHigh = Color(0xFFF1F4F8),
surfaceContainerHighest = Color(0xFFDCE2E9),
onBackground = Color(0xFF1C222B),
onSurface = Color(0xFF1C222B),
onSurfaceVariant = Color(0xFF545E6C),
outline = Color(0xFFA6AEBC),
outlineVariant = Color(0xFFD2D8E0),
)
SurfaceStyle.Watery -> SurfacePalette(
backgroundTop = Color(0xFFD6F0FF),
backgroundBottom = Color(0xFFB4DFFA),
surface = Color(0xFFF0FAFF),
surfaceVariant = Color(0xFFDCF0FC),
surfaceContainer = Color(0xFFE7F5FE),
surfaceContainerHigh = Color(0xFFEFF9FF),
surfaceContainerHighest = Color(0xFFCFE9F8),
onBackground = Color(0xFF08324A),
onSurface = Color(0xFF08324A),
onSurfaceVariant = Color(0xFF3A6884),
outline = Color(0xFF8FBBD4),
outlineVariant = Color(0xFFC6E2F2),
)
SurfaceStyle.Icy -> SurfacePalette(
backgroundTop = Color(0xFFE8F7FF),
backgroundBottom = Color(0xFFC9E8FB),
surface = Color(0xFFF6FCFF),
surfaceVariant = Color(0xFFE0F1FC),
surfaceContainer = Color(0xFFEBF7FE),
surfaceContainerHigh = Color(0xFFF3FBFF),
surfaceContainerHighest = Color(0xFFD4EBFA),
onBackground = Color(0xFF0B3A55),
onSurface = Color(0xFF0B3A55),
onSurfaceVariant = Color(0xFF3D6C88),
outline = Color(0xFF93C0D9),
outlineVariant = Color(0xFFC9E5F4),
)
SurfaceStyle.Chrome -> SurfacePalette(
backgroundTop = Color(0xFFE4E8ED),
backgroundBottom = Color(0xFFD0D6DE),
surface = Color(0xFFF4F7FA),
surfaceVariant = Color(0xFFE0E5EB),
surfaceContainer = Color(0xFFE9EDF2),
surfaceContainerHigh = Color(0xFFF1F4F7),
surfaceContainerHighest = Color(0xFFDBE0E7),
onBackground = Color(0xFF1B2733),
onSurface = Color(0xFF1B2733),
onSurfaceVariant = Color(0xFF525C6A),
outline = Color(0xFFA2AAB6),
outlineVariant = Color(0xFFD0D5DC),
)
}
} else {
when (style) {
SurfaceStyle.Glass -> SurfacePalette(
backgroundTop = Color(0xFF0E1217),
backgroundBottom = Color(0xFF161C24),
surface = Color(0xFF1E242C),
surfaceVariant = Color(0xFF262E38),
surfaceContainer = Color(0xFF1A2027),
surfaceContainerHigh = Color(0xFF232A33),
surfaceContainerHighest = Color(0xFF2C343E),
onBackground = Color(0xFFE6EDF5),
onSurface = Color(0xFFE6EDF5),
onSurfaceVariant = Color(0xFF9AA7B6),
outline = Color(0xFF46525F),
outlineVariant = Color(0xFF2C343E),
)
SurfaceStyle.Acrylic -> SurfacePalette(
backgroundTop = Color(0xFF101317),
backgroundBottom = Color(0xFF181C22),
surface = Color(0xFF20262E),
surfaceVariant = Color(0xFF282F39),
surfaceContainer = Color(0xFF1C2229),
surfaceContainerHigh = Color(0xFF252C35),
surfaceContainerHighest = Color(0xFF2E3640),
onBackground = Color(0xFFE4EAF1),
onSurface = Color(0xFFE4EAF1),
onSurfaceVariant = Color(0xFF98A2B0),
outline = Color(0xFF48525E),
outlineVariant = Color(0xFF2E3640),
)
SurfaceStyle.Watery -> SurfacePalette(
backgroundTop = Color(0xFF06121E),
backgroundBottom = Color(0xFF0B1F31),
surface = Color(0xFF0E2233),
surfaceVariant = Color(0xFF153148),
surfaceContainer = Color(0xFF0B1D2C),
surfaceContainerHigh = Color(0xFF12293C),
surfaceContainerHighest = Color(0xFF183449),
onBackground = Color(0xFFD6EAF7),
onSurface = Color(0xFFD6EAF7),
onSurfaceVariant = Color(0xFF8FB0C6),
outline = Color(0xFF3C5A72),
outlineVariant = Color(0xFF183449),
)
SurfaceStyle.Icy -> SurfacePalette(
backgroundTop = Color(0xFF0C1620),
backgroundBottom = Color(0xFF132331),
surface = Color(0xFF16283A),
surfaceVariant = Color(0xFF1E3448),
surfaceContainer = Color(0xFF122130),
surfaceContainerHigh = Color(0xFF1A2E42),
surfaceContainerHighest = Color(0xFF213A50),
onBackground = Color(0xFFDCEBF7),
onSurface = Color(0xFFDCEBF7),
onSurfaceVariant = Color(0xFF92B3CA),
outline = Color(0xFF40607A),
outlineVariant = Color(0xFF213A50),
)
SurfaceStyle.Chrome -> SurfacePalette(
backgroundTop = Color(0xFF101317),
backgroundBottom = Color(0xFF181C22),
surface = Color(0xFF232830),
surfaceVariant = Color(0xFF2B313A),
surfaceContainer = Color(0xFF1E232A),
surfaceContainerHigh = Color(0xFF272D36),
surfaceContainerHighest = Color(0xFF303742),
onBackground = Color(0xFFE7ECF2),
onSurface = Color(0xFFE7ECF2),
onSurfaceVariant = Color(0xFF9BA5B2),
outline = Color(0xFF4B5561),
outlineVariant = Color(0xFF303742),
)
}
}
}

View File

@ -48,15 +48,20 @@ import androidx.compose.ui.tooling.preview.Preview
@Composable @Composable
internal fun BaseUIPreview( internal fun BaseUIPreview(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
MaterialTheme( MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(), colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(),
) { ) {
CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) { val theme = rememberBaseUITheme(surfaceTheme = surfaceTheme)
CompositionLocalProvider(
LocalBaseUITheme provides theme,
LocalBaseUIColors provides theme.colors,
) {
Surface( Surface(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = theme.colors.surface,
) { ) {
Box(modifier = Modifier.fillMaxSize(), content = content) Box(modifier = Modifier.fillMaxSize(), content = content)
} }

View File

@ -106,6 +106,8 @@ fun DropdownMenuItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
leadingIcon: ImageVector? = null, leadingIcon: ImageVector? = null,
leadingContent: (@Composable () -> Unit)? = null,
subtitle: String? = null,
selected: Boolean = false, selected: Boolean = false,
theme: BaseUITheme.DropdownMenuTheme? = null, theme: BaseUITheme.DropdownMenuTheme? = null,
) { ) {
@ -142,8 +144,9 @@ fun DropdownMenuItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
if (leadingIcon != null) { when {
Icon( leadingContent != null -> leadingContent()
leadingIcon != null -> Icon(
imageVector = leadingIcon, imageVector = leadingIcon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
@ -155,18 +158,28 @@ fun DropdownMenuItem(
) )
} }
Text( Column(modifier = Modifier.weight(1f)) {
text = text, Text(
style = MaterialTheme.typography.bodyMedium, text = text,
color = if (enabled) { style = MaterialTheme.typography.bodyMedium,
menuTheme.itemForeground color = if (enabled) {
} else { menuTheme.itemForeground
menuTheme.itemForeground.copy(alpha = 0.38f) } else {
}, menuTheme.itemForeground.copy(alpha = 0.38f)
modifier = Modifier.weight(1f), },
maxLines = 1, maxLines = 1,
softWrap = false, 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

@ -0,0 +1,128 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* A theme-aware replacement for [Scaffold].
*
* Material's [Scaffold] paints Material's own surface colour, which hides the selected
* [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass). [BaseScaffold] instead:
*
* - is **transparent by default**, so the app-wide themed backdrop shows through, or
* - paints a chosen [surface] material locally, or
* - uses an explicit [containerColor] when one is given.
*
* It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host,
* floating action button) and hands the resolved [PaddingValues] to [content].
*/
@Composable
fun BaseScaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
surface: SurfaceStyle? = null,
containerColor: Color? = null,
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = topBar,
bottomBar = bottomBar,
snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton,
containerColor = Color.Transparent,
contentWindowInsets = contentWindowInsets,
) { innerPadding ->
val backgroundModifier = when {
containerColor != null -> Modifier.background(containerColor)
surface != null -> {
val isLight = LocalBaseUIColors.current.isLight
Modifier.baseSurfaceBackdrop(surface, isLight)
}
else -> Modifier
}
Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) {
content(innerPadding)
}
}
}
@BaseUIPhonePreview
@BaseUIDesktopPreview
@Composable
private fun BaseScaffoldPreview() {
BaseUIPreview {
val style = LocalBaseUITheme.current.surfaceTheme
BaseScaffold(
surface = style,
topBar = {
Text(
text = "Title",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
},
bottomBar = {
Text(
text = "Bottom bar",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Content")
Text("Sits on the selected surface theme.", style = MaterialTheme.typography.bodySmall)
}
}
}
}

View File

@ -0,0 +1,536 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.random.Random
import kotlinx.serialization.Serializable
/**
* The Frutiger-Aero material families available to [BaseSurface].
*
* - [Watery]: deep aqua water with caustic light and bubbles.
* - [Icy]: pale frosted ice with crystalline facets and frost.
* - [Chrome]: brushed metal with a hard specular reflection.
* - [Acrylic]: translucent tinted panel with fine grain.
* - [Glass]: clear, glossy glass with a strong sheen.
*
* Each style doubles as a full app theme (see [BaseUIColorScheme]).
*/
@Serializable
enum class SurfaceStyle {
Watery,
Icy,
Chrome,
Acrylic,
Glass,
}
/**
* A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials.
*
* Distinct from [androidx.compose.material3.Surface] — this draws decorative material
* backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching
* [LocalContentColor] so text/content inside stays legible.
*/
@Composable
fun BaseSurface(
modifier: Modifier = Modifier,
style: SurfaceStyle = SurfaceStyle.Glass,
shape: Shape = RectangleShape,
elevation: Dp? = null,
contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit,
) {
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
val onSurface = MaterialTheme.colorScheme.onSurface
val resolvedElevation = elevation ?: when (style) {
SurfaceStyle.Watery -> 14.dp
SurfaceStyle.Icy -> 12.dp
SurfaceStyle.Chrome -> 10.dp
SurfaceStyle.Acrylic -> 16.dp
SurfaceStyle.Glass -> 12.dp
}
val resolvedContentColor = contentColor ?: when (style) {
SurfaceStyle.Watery -> Color.White
SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7)
SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2)
SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface
}
val ambient = when (style) {
SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f)
SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f)
SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f)
SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f)
SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f)
}
val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f))
val borderBrush = when (style) {
SurfaceStyle.Watery -> Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)),
)
SurfaceStyle.Icy -> Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)),
)
SurfaceStyle.Chrome -> Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)),
)
SurfaceStyle.Acrylic -> Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)),
)
SurfaceStyle.Glass -> Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)),
)
}
// Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn
// behind the node and, because the panel is see-through, shows up as a dark inset
// shape. Their depth comes from the edge, sheen and bevel instead.
val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic
CompositionLocalProvider(LocalContentColor provides resolvedContentColor) {
Box(
modifier = modifier
.then(
if (castsShadow) {
Modifier.shadow(
elevation = resolvedElevation,
shape = shape,
ambientColor = ambient,
spotColor = spot,
)
} else {
Modifier
}
)
.clip(shape)
.drawWithCache {
onDrawBehind { drawSurfaceMaterial(style, isLight) }
}
.border(BorderStroke(1.dp, borderBrush), shape)
.padding(contentPadding),
contentAlignment = contentAlignment,
content = content,
)
}
}
/**
* Paints the full-bleed material of [style] as a page background: the material body plus
* its own sheen and reflections, but no panel bevel (a backdrop has no edges).
*/
fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier =
drawWithCache {
onDrawBehind { drawSurfaceBase(style, isLight) }
}
private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) {
drawSurfaceBase(style, isLight)
drawInnerBevel(isLight)
}
private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) {
when (style) {
SurfaceStyle.Watery -> drawWatery(isLight)
SurfaceStyle.Icy -> drawIcy(isLight)
SurfaceStyle.Chrome -> drawChrome(isLight)
SurfaceStyle.Acrylic -> drawAcrylic(isLight)
SurfaceStyle.Glass -> drawGlass(isLight)
}
}
private fun DrawScope.drawGlass(isLight: Boolean) {
// See-through glass: a faint blue-tinted fill so whatever is behind shows through,
// defined by its bright edge, sheen and shadow rather than an opaque body.
val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6)
drawRect(
brush = Brush.verticalGradient(
listOf(
tint.copy(alpha = if (isLight) 0.4f else 0.2f),
tint.copy(alpha = if (isLight) 0.24f else 0.1f),
),
),
)
drawGloss(if (isLight) 0.55f else 0.16f)
drawBottomReflection(if (isLight) 0.4f else 0.1f)
drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
}
private fun DrawScope.drawAcrylic(isLight: Boolean) {
// See-through acrylic: a translucent tinted sheet with a fine grain.
val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8)
drawRect(
brush = Brush.verticalGradient(
listOf(
tint.copy(alpha = if (isLight) 0.45f else 0.22f),
tint.copy(alpha = if (isLight) 0.28f else 0.12f),
),
),
)
// Fine grain so the panel reads as a translucent acrylic sheet.
val random = Random(23)
repeat(180) {
val light = random.nextBoolean()
drawCircle(
color = if (light) {
Color.White.copy(alpha = if (isLight) 0.16f else 0.06f)
} else {
Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f)
},
radius = 0.7f,
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
)
}
drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f)
}
private fun DrawScope.drawChrome(isLight: Boolean) {
if (isLight) {
drawRect(
brush = Brush.verticalGradient(
0f to Color(0xFFF8FBFF),
0.16f to Color(0xFFD6DEE7),
0.4f to Color(0xFF8E99A8),
0.5f to Color(0xFFEDF2F7),
0.62f to Color(0xFF9AA5B4),
1f to Color(0xFF6C7787),
),
)
// Hard specular reflection near the top.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent),
startY = size.height * 0.05f,
endY = size.height * 0.22f,
),
)
} else {
// Dark gunmetal for the night variant.
drawRect(
brush = Brush.verticalGradient(
0f to Color(0xFF3B424C),
0.16f to Color(0xFF2A303A),
0.4f to Color(0xFF1B2028),
0.5f to Color(0xFF343B45),
0.62f to Color(0xFF232830),
1f to Color(0xFF141820),
),
)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White.copy(alpha = 0.18f), Color.Transparent),
startY = size.height * 0.05f,
endY = size.height * 0.22f,
),
)
}
// Darkened lower edge for the metal bevel.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)),
startY = size.height * 0.78f,
endY = size.height,
),
)
}
private fun DrawScope.drawIcy(isLight: Boolean) {
val colors = if (isLight) {
listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0))
} else {
// Snowy-night ice: dim slate-blue with faint frost.
listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26))
}
drawRect(brush = Brush.verticalGradient(colors))
val frost = if (isLight) Color.White else Color(0xFFB8CCE0)
// Crystalline facets (kept inset so they are never clipped by the shape).
val random = Random(37)
repeat(4) {
val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f)
val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f)
val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f)
val path = Path().apply {
moveTo(x, y)
lineTo(x + s, y + s * 0.4f)
lineTo(x + s * 0.4f, y + s)
close()
}
drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f))
}
// Frost speckles.
repeat(90) {
drawCircle(
color = frost.copy(alpha = if (isLight) 0.5f else 0.28f),
radius = 0.8f,
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
)
}
drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f)
}
private fun DrawScope.drawWatery(isLight: Boolean) {
val colors = if (isLight) {
listOf(Color(0xFFA6E3FF), Color(0xFF3FB6EA), Color(0xFF0E6FB0))
} else {
// Moonlit night water: deep blue with a pale silver sheen.
listOf(Color(0xFF1C3A5E), Color(0xFF102942), Color(0xFF071827))
}
drawRect(brush = Brush.verticalGradient(colors))
val sheen = if (isLight) Color.White else Color(0xFFB9D2F0)
// Caustic / moonlight pooling from the surface.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent),
startY = 0f,
endY = size.height * 0.5f,
),
)
val random = Random(53)
repeat(5) {
val rx = size.width * (0.12f + random.nextFloat() * 0.2f)
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f)
drawOval(
brush = Brush.radialGradient(
colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent),
center = Offset(cx, cy),
radius = rx,
),
topLeft = Offset(cx - rx, cy - rx * 0.5f),
size = Size(rx * 2f, rx),
)
}
drawBubbles(seed = 71, count = 6, alpha = if (isLight) 0.4f else 0.22f)
}
private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White.copy(alpha = alpha), Color.Transparent),
startY = 0f,
endY = size.height * heightFraction,
),
)
}
private fun DrawScope.drawBottomReflection(alpha: Float) {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.White.copy(alpha = alpha)),
startY = size.height * 0.7f,
endY = size.height,
),
)
}
/** A bright inner top edge plus a shaded inner bottom edge, giving the pane a glass bevel. */
private fun DrawScope.drawInnerBevel(isLight: Boolean) {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), Color.Transparent),
startY = 0f,
endY = size.height * 0.1f,
),
)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = if (isLight) 0.08f else 0.28f)),
startY = size.height * 0.9f,
endY = size.height,
),
)
}
private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) {
val random = Random(seed)
repeat(count) {
val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f)
// Keep bubbles inset so they are never clipped into hard arcs at the edges.
val cx = radius + random.nextFloat() * (size.width - 2 * radius).coerceAtLeast(0f)
val cy = radius + random.nextFloat() * (size.height - 2 * radius).coerceAtLeast(0f)
val center = Offset(cx, cy)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color.White.copy(alpha = alpha), Color.White.copy(alpha = 0f)),
center = center,
radius = radius,
),
radius = radius,
center = center,
)
}
}
@BaseUIPhonePreview
@BaseUIDesktopPreview
@Composable
private fun BaseSurfacePreview() {
BaseUIPreview {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
SurfaceStyle.entries.forEach { style ->
BaseSurface(
style = style,
modifier = Modifier
.fillMaxWidth()
.height(84.dp),
) {
Text(
text = style.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
@BaseUIPhonePreview
@Composable
private fun BaseSurfaceGridPreview() {
BaseUIPreview {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
SurfaceStyle.entries.chunked(2).forEach { rowStyles ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
rowStyles.forEach { style ->
BaseSurface(
style = style,
modifier = Modifier
.weight(1f)
.height(140.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = style.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Frutiger Aero",
style = MaterialTheme.typography.labelMedium,
)
}
}
}
repeat(2 - rowStyles.size) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@BaseUIPhonePreview
@BaseUIDesktopPreview
@Composable
private fun SurfaceThemeBackdropPreview() {
val isLight = LocalBaseUIColors.current.isLight
val onSurface = LocalBaseUIColors.current.onSurface
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SurfaceStyle.entries.forEach { style ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(66.dp)
.clip(RoundedCornerShape(14.dp))
.baseSurfaceBackdrop(style, isLight),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = style.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (style == SurfaceStyle.Watery) Color.White else onSurface,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
}

View File

@ -24,15 +24,27 @@ import androidx.compose.ui.unit.dp
fun Modifier.highlight(color: Color): Modifier = fun Modifier.highlight(color: Color): Modifier =
drawWithCache { drawWithCache {
val highlightBrush = Brush.verticalGradient( // Frutiger-Aero glass: a bright specular sheen across the top half plus a
colors = listOf(color, Color.Transparent), // faint reflection rising from the bottom edge.
val topGloss = Brush.verticalGradient(
colors = listOf(color, color.copy(alpha = color.alpha * 0.25f), Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.55f,
)
val bottomReflection = Brush.verticalGradient(
colors = listOf(Color.Transparent, color.copy(alpha = color.alpha * 0.3f)),
startY = size.height * 0.72f,
endY = size.height,
) )
onDrawWithContent { onDrawWithContent {
drawContent() drawContent()
drawRect( drawRect(
brush = highlightBrush, brush = topGloss,
topLeft = androidx.compose.ui.geometry.Offset.Zero,
size = size,
)
drawRect(
brush = bottomReflection,
topLeft = androidx.compose.ui.geometry.Offset.Zero, topLeft = androidx.compose.ui.geometry.Offset.Zero,
size = size, size = size,
) )

View File

@ -44,7 +44,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -82,6 +82,8 @@ data class AdaptiveMenuItem(
val enabled: Boolean = true, val enabled: Boolean = true,
val selected: Boolean = false, val selected: Boolean = false,
val dividerBefore: Boolean = false, val dividerBefore: Boolean = false,
val subtitle: String? = null,
val leadingContent: (@Composable () -> Unit)? = null,
) )
enum class HeaderDisplayMode { enum class HeaderDisplayMode {
@ -156,6 +158,8 @@ fun AdaptiveDropdownBottomSheet(
enabled = item.enabled, enabled = item.enabled,
selected = item.selected, selected = item.selected,
leadingIcon = item.icon, leadingIcon = item.icon,
leadingContent = item.leadingContent,
subtitle = item.subtitle,
) )
} }
} }
@ -261,33 +265,32 @@ private fun AdaptiveBottomSheetContent(
) )
.padding(horizontal = 12.dp, vertical = 14.dp), .padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Row( when {
horizontalArrangement = Arrangement.spacedBy(12.dp), item.leadingContent != null -> item.leadingContent.invoke()
verticalAlignment = Alignment.CenterVertically, item.icon != null -> Icon(
) { imageVector = item.icon,
if (item.icon != null) { contentDescription = null,
Icon( modifier = Modifier.size(22.dp),
imageVector = item.icon, tint = if (item.enabled) {
contentDescription = null, MaterialTheme.colorScheme.onSurface
modifier = Modifier.size(22.dp), } else {
tint = if (item.enabled) { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
MaterialTheme.colorScheme.onSurface },
} else { )
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}, item.selected -> Icon(
) imageVector = FeatherIcons.Check,
} else if (item.selected) { contentDescription = "Selected",
Icon( modifier = Modifier.size(22.dp),
imageVector = FeatherIcons.Check, tint = MaterialTheme.colorScheme.onSurfaceVariant,
contentDescription = "Selected", )
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, hasSelection -> Spacer(modifier = Modifier.size(22.dp))
) }
} else if (hasSelection) {
Spacer(modifier = Modifier.size(22.dp)) Column(modifier = Modifier.weight(1f)) {
}
Text( Text(
text = item.label, text = item.label,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@ -297,9 +300,16 @@ private fun AdaptiveBottomSheetContent(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) 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( Icon(
imageVector = FeatherIcons.Check, imageVector = FeatherIcons.Check,
contentDescription = "Selected", contentDescription = "Selected",
@ -317,7 +327,7 @@ private fun AdaptiveBottomSheetContent(
@Preview @Preview
@Composable @Composable
private fun AdaptiveDropdownBottomSheetPreview() { private fun AdaptiveDropdownBottomSheetPreview() {
Scaffold { BaseScaffold {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = listOf( items = listOf(
AdaptiveMenuItem( AdaptiveMenuItem(

View File

@ -27,7 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -103,7 +103,7 @@ fun CollectionView(
} }
} }
Scaffold( BaseScaffold(
topBar = { topBar = {
ApplicationMainBar( ApplicationMainBar(
backButton = true, backButton = true,

View File

@ -37,7 +37,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -278,7 +278,7 @@ private fun ErrorDisplayPreview() {
@Preview @Preview
@Composable @Composable
private fun ErrorLogDialogPreview() { private fun ErrorLogDialogPreview() {
Scaffold { BaseScaffold {
ErrorLogDialog( ErrorLogDialog(
errorMessage = "This is an error message", errorMessage = "This is an error message",
onDismiss = {}, onDismiss = {},

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

@ -48,7 +48,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -1035,7 +1035,7 @@ fun previewTracks(): List<MetadataTrack> {
@Composable @Composable
@Preview @Preview
private fun TrackListPreview() { private fun TrackListPreview() {
Scaffold { BaseScaffold {
TrackList( TrackList(
tracks = previewTracks(), tracks = previewTracks(),
hasMore = true, hasMore = true,

View File

@ -38,7 +38,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -189,7 +189,7 @@ fun PlayableCard(
@PreviewLightDark @PreviewLightDark
@Composable @Composable
private fun PlayableCardPreview() { private fun PlayableCardPreview() {
Scaffold { BaseScaffold {
PlayableCard( PlayableCard(
title = "Sample Title", title = "Sample Title",
subtitle = "Sample Subtitle", subtitle = "Sample Subtitle",

View File

@ -34,7 +34,7 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -148,7 +148,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
} }
} }
Scaffold( BaseScaffold(
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
topBar = { topBar = {
ApplicationMainBar( ApplicationMainBar(

View File

@ -42,7 +42,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipBox
@ -118,7 +118,7 @@ fun ArtistScreen(
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
Scaffold( BaseScaffold(
topBar = { ApplicationMainBar() } topBar = { ApplicationMainBar() }
) { innerPadding -> ) { innerPadding ->
when (val currentState = state) { when (val currentState = state) {

View File

@ -33,7 +33,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -51,6 +50,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
@ -67,6 +67,30 @@ import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun BlacklistScreen() { fun BlacklistScreen() {
val viewModel = koinViewModel<BlacklistViewModel>() val viewModel = koinViewModel<BlacklistViewModel>()
BaseScaffold(
topBar = {
ApplicationMainBar(
backButton = true,
title = { Text("Blacklist") },
)
},
) { innerPadding ->
BlacklistContent(
viewModel = viewModel,
modifier = Modifier.padding(innerPadding),
)
}
}
/**
* The blacklist UI without its own [BaseScaffold] / 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 blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle()
val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle() val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
@ -102,92 +126,82 @@ fun BlacklistScreen() {
val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty() val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty()
val hasActiveFilter = normalizedQuery.isNotBlank() val hasActiveFilter = normalizedQuery.isNotBlank()
Scaffold( Column(
topBar = { modifier = modifier
ApplicationMainBar( .fillMaxSize()
backButton = true, .padding(horizontal = 16.dp),
title = { Text("Blacklist") }, verticalArrangement = Arrangement.spacedBy(12.dp),
) ) {
}, TextField(
) { innerPadding -> value = filterQuery,
Column( onValueChange = { filterQuery = it },
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxSize() placeholder = { Text("Filter blacklist...") },
.padding(innerPadding) singleLine = true,
.padding(horizontal = 16.dp), leadingIcon = {
verticalArrangement = Arrangement.spacedBy(12.dp), Icon(
) { imageVector = Iconsax.IconsaxFilterSearch,
TextField( contentDescription = "Search",
value = filterQuery, )
onValueChange = { filterQuery = it }, },
modifier = Modifier.fillMaxWidth(), )
placeholder = { Text("Filter blacklist...") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search",
)
},
)
if (isEmpty) { if (isEmpty) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = shellBottomInset), .padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = if (hasActiveFilter) { text = if (hasActiveFilter) {
"No blacklisted items match '$filterQuery'" "No blacklisted items match '$filterQuery'"
} else { } else {
"No blacklisted items" "No blacklisted items"
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues( contentPadding = androidx.compose.foundation.layout.PaddingValues(
bottom = 16.dp + shellBottomInset, bottom = 16.dp + shellBottomInset,
), ),
) { ) {
if (filteredTracks.isNotEmpty()) { if (filteredTracks.isNotEmpty()) {
item { item {
Text( Text(
text = "Tracks", text = "Tracks",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 8.dp),
) )
}
items(filteredTracks, key = { "track_${it.id}" }) { track ->
BlacklistedTrackRow(
track = track,
onRemove = { viewModel.toggleTrack(track) },
)
}
} }
items(filteredTracks, key = { "track_${it.id}" }) { track ->
BlacklistedTrackRow(
track = track,
onRemove = { viewModel.toggleTrack(track) },
)
}
}
if (filteredArtists.isNotEmpty()) { if (filteredArtists.isNotEmpty()) {
item { item {
Text( Text(
text = "Artists", text = "Artists",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 8.dp),
) )
} }
items(filteredArtists, key = { "artist_${it.id}" }) { artist -> items(filteredArtists, key = { "artist_${it.id}" }) { artist ->
BlacklistedArtistRow( BlacklistedArtistRow(
artist = artist, artist = artist,
onRemove = { viewModel.toggleArtist(artist) }, onRemove = { viewModel.toggleArtist(artist) },
) )
}
} }
} }
} }

View File

@ -33,7 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -77,7 +77,7 @@ fun DevicesScreen(
} }
} }
Scaffold( BaseScaffold(
topBar = { topBar = {
ApplicationMainBar( ApplicationMainBar(
backButton = true, backButton = true,

View File

@ -45,7 +45,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -107,7 +107,7 @@ fun RemoteControlScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
Scaffold( BaseScaffold(
modifier = Modifier modifier = Modifier
.fillMaxHeight(), .fillMaxHeight(),
topBar = { topBar = {

View File

@ -35,7 +35,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -120,7 +120,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
} }
} }
Scaffold( BaseScaffold(
topBar = { topBar = {
ApplicationMainBar( ApplicationMainBar(
backButton = false, backButton = false,

View File

@ -32,7 +32,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@ -71,7 +71,7 @@ fun JamScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
Scaffold( BaseScaffold(
topBar = { topBar = {
ApplicationMainBar( ApplicationMainBar(
backButton = true, backButton = true,

View File

@ -27,7 +27,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -57,7 +57,7 @@ fun LibraryScreen(
val searchMode by libraryState.searchMode.collectAsState() val searchMode by libraryState.searchMode.collectAsState()
val isLargeScreen = appShellViewModel.useSidebar() val isLargeScreen = appShellViewModel.useSidebar()
Scaffold( BaseScaffold(
topBar = { topBar = {
Column { Column {
ApplicationMainBar( ApplicationMainBar(

View File

@ -36,7 +36,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -71,7 +71,7 @@ fun LyricsScreen(
val positionMillis by viewModel.positionMillisFlow.collectAsState() val positionMillis by viewModel.positionMillisFlow.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scaffold( BaseScaffold(
topBar = { topBar = {
ApplicationMainBar( ApplicationMainBar(
backButton = onClose == null, backButton = onClose == null,

View File

@ -17,6 +17,11 @@
package dev.krtirtho.spotube.modules.plugin 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( val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
name = "NewPipe YouTube", name = "NewPipe YouTube",
version = "0.1.0", version = "0.1.0",
@ -52,3 +57,15 @@ val BUILT_IN_PLUGINS = listOf(
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
LRCLIB_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

@ -39,7 +39,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -62,6 +61,7 @@ import coil3.request.ImageRequest
import coil3.request.crossfade import coil3.request.crossfade
import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.extras.kebabToTitleCase import dev.krtirtho.spotube.core.extras.kebabToTitleCase
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
@ -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.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.getPlatform 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.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog 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.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.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.openUrlInBrowser import dev.krtirtho.spotube.openUrlInBrowser
import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo 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.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle 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.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink 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.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
import okio.Path 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_install
import spotube.composeapp.generated.resources.plugin_section_url_title import spotube.composeapp.generated.resources.plugin_section_url_title
import spotube.composeapp.generated.resources.plugin_url_placeholder 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_change
import spotube.composeapp.generated.resources.settings_plugins_action_select import spotube.composeapp.generated.resources.settings_plugins_action_select
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
@ -147,6 +144,31 @@ private val VERIFIED_PLUGIN_OWNERS = setOf<String>()
fun PluginScreen( fun PluginScreen(
viewModel: PluginViewModel = koinViewModel(), viewModel: PluginViewModel = koinViewModel(),
onboarding: Boolean = false, onboarding: Boolean = false,
) {
BaseScaffold(
topBar = {
if (!onboarding) {
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
}
},
) { innerPadding ->
PluginScreenContent(
viewModel = viewModel,
onboarding = onboarding,
modifier = Modifier.padding(innerPadding),
)
}
}
/**
* The plugin management UI without its own [BaseScaffold] / 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 uiState by viewModel.uiState.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
@ -222,28 +244,17 @@ fun PluginScreen(
} }
} }
Scaffold( Box(modifier = modifier.fillMaxSize()) {
topBar = {
if (!onboarding) {
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
}
},
containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
) { innerPadding ->
when (val data = uiState) { when (val data = uiState) {
is PluginUiState.Loading -> { is PluginUiState.Loading -> {
Box( Box(
modifier = Modifier.fillMaxSize().padding(innerPadding), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { CircularProgressIndicator() } ) { CircularProgressIndicator() }
} }
is PluginUiState.Data -> { is PluginUiState.Data -> {
Box( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
val discoverListState = rememberLazyListState() val discoverListState = rememberLazyListState()
LazyColumn( LazyColumn(
state = discoverListState, state = discoverListState,
@ -302,6 +313,19 @@ fun PluginScreen(
} }
} }
if (!onboarding || !data.onboardingDiscover) { if (!onboarding || !data.onboardingDiscover) {
item {
DefaultAbilityPluginGrid(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
selections = data.abilitySelections,
onSelected = { ability, plugin ->
viewModel.selectPlugin(ability, plugin)
},
)
}
item { item {
Row( Row(
modifier = Modifier modifier = Modifier
@ -325,39 +349,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()) { if (data.plugins.isEmpty()) {
item { item {
Box( Box(
@ -721,26 +712,22 @@ private fun PluginInfoDialog(
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(12.dp)),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
) { ) {
if (logoPath != null) { PluginLogo(
val platformContext = LocalPlatformContext.current plugin = plugin,
AsyncImage( logoPath = logoPath,
model = ImageRequest.Builder(platformContext) contentDescription = plugin.name,
.data(logoPath.toString()) modifier = Modifier.fillMaxSize(),
.crossfade(true) fallback = {
.build(), Box(contentAlignment = Alignment.Center) {
contentDescription = plugin.name, Icon(
modifier = Modifier.fillMaxSize() Iconsax.IconsaxBox,
) contentDescription = null,
} else { modifier = Modifier.size(24.dp),
Box(contentAlignment = Alignment.Center) { tint = MaterialTheme.colorScheme.primary
Icon( )
Iconsax.IconsaxBox, }
contentDescription = null, },
modifier = Modifier.size(24.dp), )
tint = MaterialTheme.colorScheme.primary
)
}
}
} }
Text( Text(
plugin.name, plugin.name,
@ -1135,31 +1122,16 @@ fun DefaultAbilityPluginSelector(
) { ) {
Surface( Surface(
modifier = Modifier.clip(RoundedCornerShape(8.dp)), modifier = Modifier.clip(RoundedCornerShape(8.dp)),
color = when (selection.ability) { color = selection.ability.accentColor().copy(alpha = 0.1f)
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
}
) { ) {
Icon( Icon(
imageVector = when (selection.ability) { imageVector = selection.ability.abilityIcon(),
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
},
contentDescription = stringResource( contentDescription = stringResource(
Res.string.settings_plugins_plugin_content_description, Res.string.settings_plugins_plugin_content_description,
selection.ability.displayLabel() selection.ability.displayLabel()
), ),
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
tint = when (selection.ability) { tint = selection.ability.accentColor()
PluginAbility.METADATA -> Color(0xFF4CAF50)
PluginAbility.AUDIO -> Color(0xFF2196F3)
PluginAbility.LYRICS -> Color(0xFFFFC107)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
}
) )
} }
@ -1203,28 +1175,13 @@ fun DefaultAbilityPluginSelector(
) { ) {
Surface( Surface(
modifier = Modifier.clip(RoundedCornerShape(8.dp)), modifier = Modifier.clip(RoundedCornerShape(8.dp)),
color = when (selection.ability) { color = selection.ability.accentColor().copy(alpha = 0.1f)
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
}
) { ) {
Icon( Icon(
imageVector = when (selection.ability) { imageVector = selection.ability.abilityIcon(),
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
},
contentDescription = null, contentDescription = null,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
tint = when (selection.ability) { tint = selection.ability.accentColor()
PluginAbility.METADATA -> Color(0xFF4CAF50)
PluginAbility.AUDIO -> Color(0xFF2196F3)
PluginAbility.LYRICS -> Color(0xFFFFC107)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
}
) )
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@ -1265,12 +1222,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 ability: PluginAbility,
val plugins: List<PluginEntry>, val plugins: List<PluginEntry>,
val selectedPlugin: PluginEntry?, val selectedPlugin: PluginEntry?,
val pluginLogoPaths: Map<String, Path?> = emptyMap(),
) )
data class PluginDiscoverState( data class PluginDiscoverState(
@ -254,22 +255,26 @@ class PluginViewModel(
AbilitySelection( AbilitySelection(
PluginAbility.METADATA, PluginAbility.METADATA,
snapshot.metadataPlugins, snapshot.metadataPlugins,
state.selectedPlugins[PluginAbility.METADATA] state.selectedPlugins[PluginAbility.METADATA],
logoPaths,
), ),
AbilitySelection( AbilitySelection(
PluginAbility.AUDIO, PluginAbility.AUDIO,
snapshot.audioPlugins, snapshot.audioPlugins,
state.selectedPlugins[PluginAbility.AUDIO] state.selectedPlugins[PluginAbility.AUDIO],
logoPaths,
), ),
AbilitySelection( AbilitySelection(
PluginAbility.LYRICS, PluginAbility.LYRICS,
snapshot.lyricsPlugins, snapshot.lyricsPlugins,
state.selectedPlugins[PluginAbility.LYRICS] state.selectedPlugins[PluginAbility.LYRICS],
logoPaths,
), ),
AbilitySelection( AbilitySelection(
PluginAbility.SCROBBLE, PluginAbility.SCROBBLE,
snapshot.scrobblePlugins, snapshot.scrobblePlugins,
state.selectedPlugins[PluginAbility.SCROBBLE] state.selectedPlugins[PluginAbility.SCROBBLE],
logoPaths,
), ),
), ),
pendingPlugin = snapshot.pendingPlugin, 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import okio.Path import okio.Path
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.OutlineButton 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_active
import spotube.composeapp.generated.resources.plugin_state_builtin import spotube.composeapp.generated.resources.plugin_state_builtin
import spotube.composeapp.generated.resources.plugin_version_label 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 @Composable
internal fun PluginCard( internal fun PluginCard(
@ -96,26 +88,22 @@ internal fun PluginCard(
.clip(RoundedCornerShape(10.dp)), .clip(RoundedCornerShape(10.dp)),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) { ) {
if (logoPath != null) { PluginLogo(
val platformContext = LocalPlatformContext.current plugin = plugin,
AsyncImage( logoPath = logoPath,
model = ImageRequest.Builder(platformContext) contentDescription = plugin.name,
.data(logoPath.toString()) modifier = Modifier.fillMaxSize(),
.crossfade(true) fallback = {
.build(), Box(contentAlignment = Alignment.Center) {
contentDescription = plugin.name, Icon(
modifier = Modifier.fillMaxSize() Iconsax.IconsaxBox,
) contentDescription = null,
} else { tint = MaterialTheme.colorScheme.primary,
Box(contentAlignment = Alignment.Center) { modifier = Modifier.size(22.dp)
Icon( )
Iconsax.IconsaxBox, }
contentDescription = null, },
tint = MaterialTheme.colorScheme.primary, )
modifier = Modifier.size(22.dp)
)
}
}
} }
// Text content // 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import okio.Path import okio.Path
import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
@ -100,24 +96,20 @@ fun PluginPermissionDialog(
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (logoPath != null) { PluginLogo(
val platformContext = LocalPlatformContext.current plugin = pluginInfo,
AsyncImage( logoPath = logoPath,
model = ImageRequest.Builder(platformContext) contentDescription = pluginInfo.name,
.data(logoPath.toString()) modifier = Modifier.fillMaxSize(),
.crossfade(true) fallback = {
.build(), Icon(
contentDescription = pluginInfo.name, imageVector = Iconsax.IconsaxBoxAdd,
modifier = Modifier.fillMaxSize() contentDescription = null,
) tint = MaterialTheme.colorScheme.primary,
} else { modifier = Modifier.size(24.dp)
Icon( )
imageVector = Iconsax.IconsaxBoxAdd, },
contentDescription = null, )
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(

View File

@ -53,7 +53,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -268,7 +268,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
remotePlaybackController.requestTracksPlayNext(tracks, "Search results") remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
} }
Scaffold( BaseScaffold(
topBar = { ApplicationMainBar(backButton = false) } topBar = { ApplicationMainBar(backButton = false) }
) { innerPadding -> ) { innerPadding ->
Column( Column(

View File

@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -16,6 +15,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
@ -58,6 +58,29 @@ import spotube.composeapp.generated.resources.settings_section_jam
@Composable @Composable
fun JamSettingsScreen( fun JamSettingsScreen(
viewModel: JamSettingsViewModel = koinViewModel(), viewModel: JamSettingsViewModel = koinViewModel(),
) {
BaseScaffold(
topBar = {
ApplicationMainBar(
title = { Text(stringResource(Res.string.settings_section_jam)) },
)
},
) { innerPadding ->
JamSettingsContent(
viewModel = viewModel,
modifier = Modifier.padding(innerPadding),
)
}
}
/**
* The Group Jam settings UI without its own [BaseScaffold] / 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 uiState by viewModel.uiState.collectAsStateWithLifecycle()
val broker = uiState.settings.jamBroker val broker = uiState.settings.jamBroker
@ -66,208 +89,199 @@ fun JamSettingsScreen(
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
} }
Scaffold( Box(
topBar = { modifier = modifier
ApplicationMainBar( .fillMaxSize(),
title = { Text(stringResource(Res.string.settings_section_jam)) }, ) {
) LazyColumn(
},
) { innerPadding ->
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .widthIn(max = 1280.dp)
.padding(innerPadding), .align(Alignment.TopCenter),
contentPadding = contentPadding,
) { ) {
LazyColumn( settingsSectionHeader(Res.string.settings_jam_section_connection)
modifier = Modifier settingsSectionCard(
.widthIn(max = 1280.dp) items = listOf(
.align(Alignment.TopCenter), {
contentPadding = contentPadding, TextInputSettingCard(
) { title = stringResource(Res.string.settings_jam_broker_host),
settingsSectionHeader(Res.string.settings_jam_section_connection) value = broker.host,
settingsSectionCard( placeholder = "broker.example.com",
items = listOf( icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") },
{ onValueSaved = { host ->
TextInputSettingCard( viewModel.update {
title = stringResource(Res.string.settings_jam_broker_host), copy(
value = broker.host, jamBroker = jamBroker.copy(
placeholder = "broker.example.com", host = host
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 errorWholeNumber =
val errorPortRange = stringResource(Res.string.settings_error_whole_number)
stringResource(Res.string.settings_error_port_range) val errorPortRange =
stringResource(Res.string.settings_error_port_range)
TextInputSettingCard( TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_port), title = stringResource(Res.string.settings_jam_broker_port),
value = broker.port.toString(), value = broker.port.toString(),
placeholder = "1883", placeholder = "1883",
normalize = { it.filter(Char::isDigit).take(5) }, normalize = { it.filter(Char::isDigit).take(5) },
validate = { value -> validate = { value ->
val port = value.toIntOrNull() val port = value.toIntOrNull()
when { when {
port == null -> errorWholeNumber port == null -> errorWholeNumber
port !in 1..65535 -> errorPortRange port !in 1..65535 -> errorPortRange
else -> null else -> null
} }
}, },
onValueSaved = { port -> onValueSaved = { port ->
viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) } viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.CustomServer, Iconsax.CustomServer,
stringResource(Res.string.settings_jam_broker_port) stringResource(Res.string.settings_jam_broker_port)
) )
}, },
) )
}, },
{ {
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_jam_broker_tls), title = stringResource(Res.string.settings_jam_broker_tls),
subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle), subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
checked = broker.useTls, checked = broker.useTls,
onCheckedChange = { tls -> onCheckedChange = { tls ->
viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) } viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxWifiSquare, Iconsax.IconsaxWifiSquare,
stringResource(Res.string.settings_jam_broker_tls) stringResource(Res.string.settings_jam_broker_tls)
) )
}, },
) )
}, },
), ),
) )
settingsSectionHeader(Res.string.settings_jam_section_credentials) settingsSectionHeader(Res.string.settings_jam_section_credentials)
settingsSectionCard( settingsSectionCard(
items = listOf( items = listOf(
{ {
TextInputSettingCard( TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_username), title = stringResource(Res.string.settings_jam_broker_username),
value = broker.username.orEmpty(), value = broker.username.orEmpty(),
placeholder = "anonymous", placeholder = "anonymous",
onValueSaved = { username -> onValueSaved = { username ->
viewModel.update { viewModel.update {
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
} }
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.User, Iconsax.User,
stringResource(Res.string.settings_jam_broker_username) stringResource(Res.string.settings_jam_broker_username)
) )
}, },
) )
}, },
{ {
TextInputSettingCard( TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_password), title = stringResource(Res.string.settings_jam_broker_password),
value = broker.password.orEmpty(), value = broker.password.orEmpty(),
placeholder = "••••••••", placeholder = "••••••••",
onValueSaved = { password -> onValueSaved = { password ->
viewModel.update { viewModel.update {
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
} }
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxEye, Iconsax.IconsaxEye,
stringResource(Res.string.settings_jam_broker_password) stringResource(Res.string.settings_jam_broker_password)
) )
}, },
) )
}, },
), ),
) )
settingsSectionHeader(Res.string.settings_jam_section_identity) settingsSectionHeader(Res.string.settings_jam_section_identity)
settingsSectionCard( settingsSectionCard(
items = listOf( items = listOf(
{ {
TextInputSettingCard( TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_client_id), title = stringResource(Res.string.settings_jam_broker_client_id),
subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle), subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
value = broker.clientIdPrefix, value = broker.clientIdPrefix,
placeholder = "spotube", placeholder = "spotube",
onValueSaved = { prefix -> onValueSaved = { prefix ->
viewModel.update { viewModel.update {
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
} }
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxTag, Iconsax.IconsaxTag,
stringResource(Res.string.settings_jam_broker_client_id) stringResource(Res.string.settings_jam_broker_client_id)
) )
}, },
) )
}, },
), ),
) )
settingsSectionHeader(Res.string.settings_jam_section_diagnostics) settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
settingsSectionCard( settingsSectionCard(
items = listOf( items = listOf(
{ {
val canTest = broker.host.isNotBlank() && !uiState.isTesting val canTest = broker.host.isNotBlank() && !uiState.isTesting
val resultMessage = uiState.testResult?.fold( val resultMessage = uiState.testResult?.fold(
onSuccess = { it }, onSuccess = { it },
onFailure = { "Failed: ${it.message ?: "Connection failed"}" }, onFailure = { "Failed: ${it.message ?: "Connection failed"}" },
) )
SettingCardItem( SettingCardItem(
enabled = canTest, enabled = canTest,
title = stringResource(Res.string.settings_jam_broker_test), title = stringResource(Res.string.settings_jam_broker_test),
subtitle = when { subtitle = when {
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing) uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
resultMessage != null -> resultMessage resultMessage != null -> resultMessage
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle) else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxInformation, Iconsax.IconsaxInformation,
stringResource(Res.string.settings_jam_broker_test), 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,
onClick = viewModel::testConnection, )
enabled = canTest, },
) { ),
Text( )
if (uiState.isTesting) { item {
stringResource(Res.string.settings_jam_broker_testing) Text(
} else { text = stringResource(Res.string.settings_jam_broker_placeholder_note),
stringResource(Res.string.settings_jam_broker_test) modifier = Modifier
}, .fillMaxWidth()
) .padding(horizontal = 16.dp, vertical = 4.dp),
}
},
onClick = viewModel::testConnection,
)
},
),
) )
item {
Text(
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
} }
} }
} }

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.settings
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import dev.krtirtho.spotube.core.ui.base.SurfaceStyle
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@ -35,6 +36,7 @@ data class UserSettings(
// Appearance // Appearance
val theme: Theme = Theme.SYSTEM, val theme: Theme = Theme.SYSTEM,
val accentColor: AccentColors = AccentColors.GREEN_GOBLIN, val accentColor: AccentColors = AccentColors.GREEN_GOBLIN,
val surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
// Playback // Playback
val streamingMusicFormat: AudioFormat = AudioFormat( val streamingMusicFormat: AudioFormat = AudioFormat(

View File

@ -18,13 +18,13 @@
package dev.krtirtho.spotube.modules.settings package dev.krtirtho.spotube.modules.settings
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -37,6 +37,8 @@ import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.modules.settings.sections.appearanceSection import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
@ -52,24 +54,44 @@ import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen(settingsViewModel: SettingsViewModel) { private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) {
val navigatorCommands = koinInject<NavigationCommands>() val navigatorCommands = koinInject<NavigationCommands>()
val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() 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 shellBottomInset = LocalAppShellBottomInset.current
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
val contentPadding = remember(shellBottomInset) { val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
} }
Scaffold( BaseScaffold(
topBar = { topBar = {
ApplicationMainBar( ApplicationMainBar(
title = { title = {
@ -79,66 +101,79 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
) )
} }
) { innerPadding -> ) { innerPadding ->
Box( CompactSettingsContent(
modifier = Modifier settings = settingsState,
.fillMaxSize() settingsViewModel = settingsViewModel,
.padding(innerPadding) navigatorCommands = navigatorCommands,
) { requestLocalNetworkPermission = requestLocalNetworkPermission,
LazyColumn( contentPadding = contentPadding,
modifier = Modifier modifier = Modifier.padding(innerPadding),
.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,
)
}
@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,
)
}
} }
} }
} }

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

@ -52,6 +52,7 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.Radio import dev.krtirtho.spotube.core.ui.base.Radio
import dev.krtirtho.spotube.core.ui.base.SurfaceStyle
import dev.krtirtho.spotube.modules.settings.AccentColors import dev.krtirtho.spotube.modules.settings.AccentColors
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.Theme
@ -61,14 +62,18 @@ import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
internal fun LazyListScope.appearanceSection( internal fun LazyListScope.appearanceSection(
settings: UserSettings, settings: UserSettings,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true,
) { ) {
settingsSectionHeader(Res.string.settings_section_appearance) if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_appearance)
}
settingsSectionCard( settingsSectionCard(
items = listOf( items = listOf(
{ {
@ -91,6 +96,29 @@ internal fun LazyListScope.appearanceSection(
} }
) )
}, },
{
SelectionSettingCard(
title = stringResource(Res.string.settings_surface_theme_title),
subtitle = stringResource(
Res.string.settings_surface_theme_subtitle_current,
settings.surfaceTheme.displayLabel()
),
icon = {
SettingsItemIcon(
Iconsax.IconsaxMagic,
stringResource(Res.string.settings_surface_theme_title)
)
},
selectedOption = settings.surfaceTheme,
options = SurfaceStyle.entries,
optionLabel = { it.displayLabel() },
onOptionSelected = { surfaceTheme ->
settingsViewModel.updateSettings {
copy(surfaceTheme = surfaceTheme)
}
}
)
},
{ {
AccentColorSettingCard( AccentColorSettingCard(
selectedAccent = settings.accentColor, selectedAccent = settings.accentColor,
@ -117,6 +145,17 @@ private fun Theme.displayLabel(): String {
} }
} }
@Composable
private fun SurfaceStyle.displayLabel(): String {
return when (this) {
SurfaceStyle.Watery -> stringResource(Res.string.settings_surface_watery)
SurfaceStyle.Icy -> stringResource(Res.string.settings_surface_icy)
SurfaceStyle.Chrome -> stringResource(Res.string.settings_surface_chrome)
SurfaceStyle.Acrylic -> stringResource(Res.string.settings_surface_acrylic)
SurfaceStyle.Glass -> stringResource(Res.string.settings_surface_glass)
}
}
@Composable @Composable
private fun AccentColors.displayLabel(): String { private fun AccentColors.displayLabel(): String {
return when (this) { return when (this) {

View File

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

View File

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

View File

@ -79,6 +79,7 @@ import spotube.composeapp.generated.resources.settings_subtitle_current
internal fun LazyListScope.downloadsSection( internal fun LazyListScope.downloadsSection(
settings: UserSettings, settings: UserSettings,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true,
) { ) {
val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets)
val downloadQualities = availableAudioQualities( val downloadQualities = availableAudioQualities(
@ -86,7 +87,9 @@ internal fun LazyListScope.downloadsSection(
current = settings.downloadMusicQuality, current = settings.downloadMusicQuality,
) )
settingsSectionHeader(Res.string.settings_section_downloads) if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_downloads)
}
settingsSectionCard( settingsSectionCard(
items = listOf( items = listOf(
{ {

View File

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

View File

@ -20,6 +20,7 @@ package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.ChevronRight
import compose.icons.feathericons.Cast import compose.icons.feathericons.Cast
@ -51,6 +52,8 @@ internal fun LazyListScope.playbackSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
navigatorCommands: NavigationCommands, navigatorCommands: NavigationCommands,
requestLocalNetworkPermission: () -> Unit, requestLocalNetworkPermission: () -> Unit,
includeBlacklist: Boolean = true,
includeHeader: Boolean = true,
) { ) {
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
val streamingQualities = availableAudioQualities( val streamingQualities = availableAudioQualities(
@ -58,163 +61,162 @@ internal fun LazyListScope.playbackSection(
current = settings.streamingMusicQuality, current = settings.streamingMusicQuality,
) )
settingsSectionHeader(Res.string.settings_section_playback) val cards: List<@Composable () -> Unit> = buildList {
settingsSectionCard( add {
items = listOf( SelectionSettingCard(
{ title = stringResource(Res.string.settings_streaming_format_title),
SelectionSettingCard( subtitle = stringResource(
title = stringResource(Res.string.settings_streaming_format_title), Res.string.settings_streaming_format_subtitle_current,
subtitle = stringResource( settings.streamingMusicFormat.displayLabel()
Res.string.settings_streaming_format_subtitle_current, ),
settings.streamingMusicFormat.displayLabel() icon = {
), SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title))
icon = { },
SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title)) selectedOption = settings.streamingMusicFormat,
}, options = streamingFormats,
selectedOption = settings.streamingMusicFormat, optionLabel = { it.displayLabel() },
options = streamingFormats, onOptionSelected = { format ->
optionLabel = { it.displayLabel() }, settingsViewModel.updateSettings {
onOptionSelected = { format -> copy(
settingsViewModel.updateSettings { streamingMusicFormat = format,
copy( streamingMusicQuality = format.resolveQuality(streamingMusicQuality),
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)
) )
},
checked = settings.enableEndlessPlayback,
onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
copy(enableEndlessPlayback = enabled)
}
} }
) }
}, )
{ }
SwitchSettingCard( add {
title = stringResource(Res.string.settings_enable_connect_title), SelectionSettingCard(
subtitle = stringResource(Res.string.settings_enable_connect_subtitle), title = stringResource(Res.string.settings_streaming_quality_title),
icon = { subtitle = stringResource(
SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title)) Res.string.settings_subtitle_current,
}, settings.streamingMusicQuality.displayLabel()
checked = settings.enableConnect, ),
onCheckedChange = { enabled -> icon = {
settingsViewModel.updateSettings { SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title))
copy(enableConnect = enabled) },
} selectedOption = settings.streamingMusicQuality,
options = streamingQualities,
optionLabel = { it.displayLabel() },
onOptionSelected = { quality ->
settingsViewModel.updateSettings {
copy(streamingMusicQuality = quality)
} }
) }
}, )
{ }
SwitchSettingCard( add {
title = stringResource(Res.string.settings_allow_remote_control_title), SwitchSettingCard(
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), title = stringResource(Res.string.settings_enable_endless_playback_title),
icon = { subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
SettingsItemIcon( icon = {
Iconsax.IconsaxMirroringScreen, SettingsItemIcon(
stringResource(Res.string.settings_allow_remote_control_title) Iconsax.IconsaxRepeatArrow,
) stringResource(Res.string.settings_enable_endless_playback_title)
}, )
checked = settings.allowRemoteControl, },
onCheckedChange = { enabled -> checked = settings.enableEndlessPlayback,
settingsViewModel.updateSettings { onCheckedChange = { enabled ->
copy(allowRemoteControl = enabled) settingsViewModel.updateSettings {
} copy(enableEndlessPlayback = enabled)
// Request the local network permission when enabling remote control
// so that DNS-SD registration can succeed on Android 16+
if (enabled) {
requestLocalNetworkPermission()
}
} }
) }
}, )
{ }
TextInputSettingCard( add {
title = stringResource(Res.string.settings_remote_device_name_title), SwitchSettingCard(
subtitle = stringResource( title = stringResource(Res.string.settings_enable_connect_title),
Res.string.settings_remote_device_name_subtitle, subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } icon = {
), SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
icon = { },
SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) checked = settings.enableConnect,
}, onCheckedChange = { enabled ->
value = settings.remoteControlDeviceName, settingsViewModel.updateSettings {
dialogDescription = stringResource(Res.string.settings_remote_device_name_description), copy(enableConnect = enabled)
placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
normalize = { it.trim() },
validate = { _ -> null },
onValueSaved = { value ->
settingsViewModel.updateSettings {
copy(remoteControlDeviceName = value)
}
} }
) }
}, )
{ }
val error_whole_number = stringResource(Res.string.settings_error_whole_number) add {
val error_port_range = stringResource(Res.string.settings_error_port_range) 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( TextInputSettingCard(
title = stringResource(Res.string.settings_playback_port_title), title = stringResource(Res.string.settings_playback_port_title),
subtitle = stringResource( subtitle = stringResource(
Res.string.settings_playback_port_subtitle_current, Res.string.settings_playback_port_subtitle_current,
settings.playbackProxyServerPort settings.playbackProxyServerPort
), ),
icon = { icon = {
SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title)) SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title))
}, },
value = settings.playbackProxyServerPort.toString(), value = settings.playbackProxyServerPort.toString(),
dialogDescription = stringResource(Res.string.settings_playback_port_description), dialogDescription = stringResource(Res.string.settings_playback_port_description),
placeholder = stringResource(Res.string.settings_playback_port_placeholder), placeholder = stringResource(Res.string.settings_playback_port_placeholder),
normalize = { it.trim() }, normalize = { it.trim() },
validate = { value -> validate = { value ->
val port = value.toIntOrNull() val port = value.toIntOrNull()
when { when {
port == null -> error_whole_number port == null -> error_whole_number
port !in 1..65535 -> error_port_range port !in 1..65535 -> error_port_range
else -> null else -> null
}
},
onValueSaved = { value ->
settingsViewModel.updateSettings {
copy(playbackProxyServerPort = value.toInt())
}
} }
) },
}, onValueSaved = { value ->
{ settingsViewModel.updateSettings {
copy(playbackProxyServerPort = value.toInt())
}
}
)
}
if (includeBlacklist) {
add {
SettingCardItem( SettingCardItem(
title = stringResource(Res.string.settings_blacklist_title), title = stringResource(Res.string.settings_blacklist_title),
subtitle = stringResource(Res.string.settings_blacklist_subtitle), subtitle = stringResource(Res.string.settings_blacklist_subtitle),
@ -235,8 +237,12 @@ internal fun LazyListScope.playbackSection(
navigatorCommands.navigateTo(Routes.Blacklist) 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( internal fun LazyListScope.updatesSection(
settings: UserSettings, settings: UserSettings,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true,
) { ) {
settingsSectionHeader(Res.string.settings_section_updates) if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_updates)
}
settingsSectionCard( settingsSectionCard(
items = listOf { items = listOf {
SwitchSettingCard( SwitchSettingCard(

View File

@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
@ -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 @Composable
internal fun SettingsItemIcon( internal fun SettingsItemIcon(
imageVector: ImageVector, imageVector: ImageVector,

View File

@ -47,7 +47,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -235,7 +235,7 @@ fun AppExpandedPlayer(
} }
} }
Scaffold( BaseScaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { innerPadding -> ) { innerPadding ->

View File

@ -74,6 +74,8 @@ import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
import dev.krtirtho.spotube.core.ui.base.baseSurfaceBackdrop
import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
@ -100,6 +102,7 @@ fun AppShell(
) { ) {
val navigatorCommands: NavigationCommands = koinInject() val navigatorCommands: NavigationCommands = koinInject()
val remotePlaybackController: RemotePlaybackController = koinInject() val remotePlaybackController: RemotePlaybackController = koinInject()
val baseUIColors = LocalBaseUIColors.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(remotePlaybackController) { LaunchedEffect(remotePlaybackController) {
remotePlaybackController.events.collect { message -> remotePlaybackController.events.collect { message ->
@ -156,7 +159,7 @@ fun AppShell(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight)
.hazeSource(state = bgHazeState) // <-- Put it here! .hazeSource(state = bgHazeState) // <-- Put it here!
) )
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
@ -229,6 +232,11 @@ fun AppShell(
} }
} else { } else {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight),
)
CompactPlayerOverlay( CompactPlayerOverlay(
navigator = navigator, navigator = navigator,
navigationState = navigationState, navigationState = navigationState,

View File

@ -24,23 +24,18 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -51,24 +46,17 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.krtirtho.spotube.NavigationItem import dev.krtirtho.spotube.NavigationItem
import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.component.SidebarItem
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel
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.VerticalScrollbar import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryState
@ -94,21 +82,13 @@ fun AppSidebar(
var expanded by rememberSaveable { mutableStateOf(true) } var expanded by rememberSaveable { mutableStateOf(true) }
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp) val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
val currentLibraryTab by libraryState.currentTab.collectAsState() val currentLibraryTab by libraryState.currentTab.collectAsState()
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
val listState = rememberLazyListState() val listState = rememberLazyListState()
Column( Column(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(width) .width(width),
.hazeEffect(hazeState) {
blurEffect {
blurRadius = 20.dp
colorEffects = listOf(
HazeColorEffect.tint(surfaceTint)
)
}
},
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@ -150,20 +130,12 @@ fun AppSidebar(
items(sidebarTabs.size) { index -> items(sidebarTabs.size) { index ->
when (val tab = sidebarTabs[index]) { when (val tab = sidebarTabs[index]) {
is NavigationItem.Group<*> -> { is NavigationItem.Group<*> -> {
androidx.compose.animation.AnimatedVisibility(visible = expanded) { SidebarSectionLabel(text = tab.title, expanded = expanded)
Text(
text = tab.title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
)
}
tab.items.forEach { tab.items.forEach {
val selected = navigationState.topLevelRoute == it.route val selected = navigationState.topLevelRoute == it.route
SidebarItem( SidebarItem(
label = it.title, label = it.title,
activeIcon = it.icon, icon = it.icon,
onClick = { onClick = {
if (it.route == Routes.Library) { if (it.route == Routes.Library) {
libraryState.currentTab.value = libraryState.currentTab.value =
@ -173,8 +145,17 @@ fun AppSidebar(
}, },
selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected, selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected,
expanded = expanded, expanded = expanded,
showDownloadBadge = it.route == Routes.Library badge = if (it.route == Routes.Library
&& it.data == LibraryTab.Downloads && it.data == LibraryTab.Downloads
) {
{
DownloadBadgeIndicator(
modifier = Modifier.align(Alignment.TopEnd)
)
}
} else {
null
},
) )
} }
@ -184,7 +165,7 @@ fun AppSidebar(
val selected = navigationState.topLevelRoute == tab.route val selected = navigationState.topLevelRoute == tab.route
SidebarItem( SidebarItem(
label = tab.title, label = tab.title,
activeIcon = tab.icon, icon = tab.icon,
onClick = { onClick = {
navigator.navigate(tab.route) navigator.navigate(tab.route)
}, },
@ -206,7 +187,7 @@ fun AppSidebar(
HorizontalDivider() HorizontalDivider()
SidebarItem( SidebarItem(
label = "Settings", label = "Settings",
activeIcon = Iconsax.IconsaxSetting2, icon = Iconsax.IconsaxSetting2,
onClick = { onClick = {
navigator.navigate(Routes.Settings) navigator.navigate(Routes.Settings)
}, },
@ -216,75 +197,3 @@ fun AppSidebar(
Spacer(modifier = Modifier.height(120.dp)) 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,
)
}
}

View File

@ -41,7 +41,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -79,7 +79,7 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
WelcomeTheme { WelcomeTheme {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
WelcomeBackground() WelcomeBackground()
Scaffold( BaseScaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
topBar = { topBar = {
ApplicationMainBar( ApplicationMainBar(

View File

@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.Font
@ -60,7 +61,11 @@ fun WelcomeTheme(content: @Composable () -> Unit) {
outlineVariant = Color(0xFFD2D0CC), outlineVariant = Color(0xFFD2D0CC),
) )
MaterialTheme(colorScheme = scheme) { MaterialTheme(colorScheme = scheme) {
CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) { val baseTheme = rememberBaseUITheme()
CompositionLocalProvider(
LocalBaseUITheme provides baseTheme,
LocalBaseUIColors provides baseTheme.colors,
) {
content() content()
} }
} }

View File

@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPlacement
import dev.krtirtho.spotube.core.systemtray.SystemTrayService import dev.krtirtho.spotube.core.systemtray.SystemTrayService
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
import dev.krtirtho.spotube.resources.iconsax.FluentMaximize import dev.krtirtho.spotube.resources.iconsax.FluentMaximize
@ -63,6 +64,7 @@ actual fun ApplicationMainBar(
backButton: Boolean, backButton: Boolean,
transparent: Boolean, transparent: Boolean,
) { ) {
val colors = LocalBaseUIColors.current
Box( Box(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
.windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize) .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize)
@ -82,9 +84,19 @@ actual fun ApplicationMainBar(
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
titleContentColor = colors.onSurface,
actionIconContentColor = colors.onSurfaceVariant,
navigationIconContentColor = colors.onSurfaceVariant,
) )
} else { } else {
TopAppBarDefaults.topAppBarColors() // The app bar follows the selected surface theme rather than Material's.
TopAppBarDefaults.topAppBarColors(
containerColor = colors.surface,
scrolledContainerColor = colors.surfaceContainer,
titleContentColor = colors.onSurface,
actionIconContentColor = colors.onSurfaceVariant,
navigationIconContentColor = colors.onSurfaceVariant,
)
}, },
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
) )

View File

@ -17,26 +17,43 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.foundation.ScrollbarStyle
import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
@Composable @Composable
actual fun VerticalScrollbar( actual fun VerticalScrollbar(
listState: LazyListState, listState: LazyListState,
modifier: Modifier modifier: Modifier
) { ) {
VerticalScrollbar( val colors = LocalBaseUIColors.current
adapter = rememberScrollbarAdapter(listState), CompositionLocalProvider(
modifier = modifier.fillMaxHeight() LocalScrollbarStyle provides ScrollbarStyle(
.padding(end = 2.dp) minimalHeight = 16.dp,
.padding(vertical = 16.dp) thickness = 8.dp,
.width(12.dp), shape = RoundedCornerShape(4.dp),
) hoverDurationMillis = 300,
unhoverColor = colors.onSurface.copy(alpha = 0.18f),
hoverColor = colors.onSurface.copy(alpha = 0.4f),
),
) {
VerticalScrollbar(
adapter = rememberScrollbarAdapter(listState),
modifier = modifier.fillMaxHeight()
.padding(end = 2.dp)
.padding(vertical = 16.dp)
.width(12.dp),
)
}
} }