feat(settings): add 'About' section and improve header visibility in settings sections
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@ -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 |
@ -26,6 +26,7 @@
|
||||
<string name="settings_section_caching">Caching</string>
|
||||
<string name="settings_section_desktop">Desktop</string>
|
||||
<string name="settings_section_updates">Updates</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
|
||||
<string name="settings_action_close">Close</string>
|
||||
<string name="settings_action_save">Save</string>
|
||||
|
||||
@ -106,6 +106,8 @@ fun DropdownMenuItem(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
subtitle: String? = null,
|
||||
selected: Boolean = false,
|
||||
theme: BaseUITheme.DropdownMenuTheme? = null,
|
||||
) {
|
||||
@ -142,8 +144,9 @@ fun DropdownMenuItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
Icon(
|
||||
when {
|
||||
leadingContent != null -> leadingContent()
|
||||
leadingIcon != null -> Icon(
|
||||
imageVector = leadingIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
@ -155,6 +158,7 @@ fun DropdownMenuItem(
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@ -163,10 +167,19 @@ fun DropdownMenuItem(
|
||||
} else {
|
||||
menuTheme.itemForeground.copy(alpha = 0.38f)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = menuTheme.itemForeground.copy(alpha = if (enabled) 0.6f else 0.38f),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -82,6 +82,8 @@ data class AdaptiveMenuItem(
|
||||
val enabled: Boolean = true,
|
||||
val selected: Boolean = false,
|
||||
val dividerBefore: Boolean = false,
|
||||
val subtitle: String? = null,
|
||||
val leadingContent: (@Composable () -> Unit)? = null,
|
||||
)
|
||||
|
||||
enum class HeaderDisplayMode {
|
||||
@ -156,6 +158,8 @@ fun AdaptiveDropdownBottomSheet(
|
||||
enabled = item.enabled,
|
||||
selected = item.selected,
|
||||
leadingIcon = item.icon,
|
||||
leadingContent = item.leadingContent,
|
||||
subtitle = item.subtitle,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -261,14 +265,11 @@ private fun AdaptiveBottomSheetContent(
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (item.icon != null) {
|
||||
Icon(
|
||||
when {
|
||||
item.leadingContent != null -> item.leadingContent.invoke()
|
||||
item.icon != null -> Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
@ -278,16 +279,18 @@ private fun AdaptiveBottomSheetContent(
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
} else if (item.selected) {
|
||||
Icon(
|
||||
|
||||
item.selected -> Icon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (hasSelection) {
|
||||
Spacer(modifier = Modifier.size(22.dp))
|
||||
|
||||
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@ -297,9 +300,16 @@ private fun AdaptiveBottomSheetContent(
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
item.subtitle?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.selected && item.icon != null) {
|
||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -67,6 +67,30 @@ import org.koin.compose.viewmodel.koinViewModel
|
||||
@Composable
|
||||
fun BlacklistScreen() {
|
||||
val viewModel = koinViewModel<BlacklistViewModel>()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text("Blacklist") },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
BlacklistContent(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blacklist UI without its own [Scaffold] / app bar, so it can be embedded
|
||||
* as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen].
|
||||
*/
|
||||
@Composable
|
||||
internal fun BlacklistContent(
|
||||
viewModel: BlacklistViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle()
|
||||
val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -102,18 +126,9 @@ fun BlacklistScreen() {
|
||||
val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty()
|
||||
val hasActiveFilter = normalizedQuery.isNotBlank()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text("Blacklist") },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@ -193,7 +208,6 @@ fun BlacklistScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlacklistedTrackRow(
|
||||
|
||||
@ -17,6 +17,11 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.plugin
|
||||
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.plugin_logo_lrclib
|
||||
import spotube.composeapp.generated.resources.plugin_logo_newpipe
|
||||
|
||||
val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
|
||||
name = "NewPipe YouTube",
|
||||
version = "0.1.0",
|
||||
@ -52,3 +57,15 @@ val BUILT_IN_PLUGINS = listOf(
|
||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
|
||||
LRCLIB_BUILT_IN_PLUGIN,
|
||||
)
|
||||
|
||||
/**
|
||||
* The bundled placeholder logo for a built-in plugin, or `null` if it has none.
|
||||
*
|
||||
* Built-in plugins are not installed to disk, so they have no `logo.png`; these
|
||||
* resources (in `composeResources/drawable`) stand in as their logos.
|
||||
*/
|
||||
fun builtInPluginLogo(plugin: PluginEntry): DrawableResource? = when (plugin.id) {
|
||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_newpipe
|
||||
LRCLIB_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_lrclib
|
||||
else -> null
|
||||
}
|
||||
|
||||
@ -75,9 +75,14 @@ import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid
|
||||
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
|
||||
import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog
|
||||
import dev.krtirtho.spotube.modules.plugin.components.PluginLogo
|
||||
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
|
||||
import dev.krtirtho.spotube.modules.plugin.components.abilityIcon
|
||||
import dev.krtirtho.spotube.modules.plugin.components.accentColor
|
||||
import dev.krtirtho.spotube.modules.plugin.components.displayLabel
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.openUrlInBrowser
|
||||
import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo
|
||||
@ -86,16 +91,12 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft
|
||||
import io.github.vinceglb.filekit.dialogs.FileKitType
|
||||
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
|
||||
import okio.Path
|
||||
@ -127,10 +128,6 @@ import spotube.composeapp.generated.resources.plugin_section_file_title
|
||||
import spotube.composeapp.generated.resources.plugin_section_install
|
||||
import spotube.composeapp.generated.resources.plugin_section_url_title
|
||||
import spotube.composeapp.generated.resources.plugin_url_placeholder
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
|
||||
import spotube.composeapp.generated.resources.settings_plugins_action_change
|
||||
import spotube.composeapp.generated.resources.settings_plugins_action_select
|
||||
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
|
||||
@ -147,6 +144,32 @@ private val VERIFIED_PLUGIN_OWNERS = setOf<String>()
|
||||
fun PluginScreen(
|
||||
viewModel: PluginViewModel = koinViewModel(),
|
||||
onboarding: Boolean = false,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!onboarding) {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
||||
}
|
||||
},
|
||||
containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
|
||||
) { innerPadding ->
|
||||
PluginScreenContent(
|
||||
viewModel = viewModel,
|
||||
onboarding = onboarding,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin management UI without its own [Scaffold] / app bar, so it can be embedded
|
||||
* as a tab in the large-screen settings layout as well as hosted by [PluginScreen].
|
||||
*/
|
||||
@Composable
|
||||
internal fun PluginScreenContent(
|
||||
viewModel: PluginViewModel,
|
||||
onboarding: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -222,28 +245,17 @@ fun PluginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!onboarding) {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
||||
}
|
||||
},
|
||||
containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
|
||||
) { innerPadding ->
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (val data = uiState) {
|
||||
is PluginUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
|
||||
is PluginUiState.Data -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val discoverListState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = discoverListState,
|
||||
@ -302,6 +314,19 @@ fun PluginScreen(
|
||||
}
|
||||
}
|
||||
if (!onboarding || !data.onboardingDiscover) {
|
||||
|
||||
item {
|
||||
DefaultAbilityPluginGrid(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
selections = data.abilitySelections,
|
||||
onSelected = { ability, plugin ->
|
||||
viewModel.selectPlugin(ability, plugin)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -325,39 +350,6 @@ fun PluginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
data.abilitySelections.forEachIndexed { index, selection ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(
|
||||
alpha = 0.5f
|
||||
),
|
||||
)
|
||||
}
|
||||
DefaultAbilityPluginSelector(
|
||||
selection = selection,
|
||||
onSelected = { plugin ->
|
||||
viewModel.selectPlugin(
|
||||
selection.ability,
|
||||
plugin
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.plugins.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
@ -721,17 +713,12 @@ private fun PluginInfoDialog(
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
) {
|
||||
if (logoPath != null) {
|
||||
val platformContext = LocalPlatformContext.current
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(platformContext)
|
||||
.data(logoPath.toString())
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
PluginLogo(
|
||||
plugin = plugin,
|
||||
logoPath = logoPath,
|
||||
contentDescription = plugin.name,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
fallback = {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Iconsax.IconsaxBox,
|
||||
@ -740,7 +727,8 @@ private fun PluginInfoDialog(
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Text(
|
||||
plugin.name,
|
||||
@ -1135,31 +1123,16 @@ fun DefaultAbilityPluginSelector(
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||
color = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
||||
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
|
||||
}
|
||||
color = selection.ability.accentColor().copy(alpha = 0.1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
|
||||
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
|
||||
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
|
||||
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
|
||||
},
|
||||
imageVector = selection.ability.abilityIcon(),
|
||||
contentDescription = stringResource(
|
||||
Res.string.settings_plugins_plugin_content_description,
|
||||
selection.ability.displayLabel()
|
||||
),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
tint = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Color(0xFF4CAF50)
|
||||
PluginAbility.AUDIO -> Color(0xFF2196F3)
|
||||
PluginAbility.LYRICS -> Color(0xFFFFC107)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
|
||||
}
|
||||
tint = selection.ability.accentColor()
|
||||
)
|
||||
}
|
||||
|
||||
@ -1203,28 +1176,13 @@ fun DefaultAbilityPluginSelector(
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||
color = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
||||
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
|
||||
}
|
||||
color = selection.ability.accentColor().copy(alpha = 0.1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
|
||||
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
|
||||
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
|
||||
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
|
||||
},
|
||||
imageVector = selection.ability.abilityIcon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
tint = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Color(0xFF4CAF50)
|
||||
PluginAbility.AUDIO -> Color(0xFF2196F3)
|
||||
PluginAbility.LYRICS -> Color(0xFFFFC107)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
|
||||
}
|
||||
tint = selection.ability.accentColor()
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@ -1265,12 +1223,3 @@ fun DefaultAbilityPluginSelector(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginAbility.displayLabel(): String {
|
||||
return when (this) {
|
||||
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
|
||||
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
|
||||
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
|
||||
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,6 +63,7 @@ data class AbilitySelection(
|
||||
val ability: PluginAbility,
|
||||
val plugins: List<PluginEntry>,
|
||||
val selectedPlugin: PluginEntry?,
|
||||
val pluginLogoPaths: Map<String, Path?> = emptyMap(),
|
||||
)
|
||||
|
||||
data class PluginDiscoverState(
|
||||
@ -254,22 +255,26 @@ class PluginViewModel(
|
||||
AbilitySelection(
|
||||
PluginAbility.METADATA,
|
||||
snapshot.metadataPlugins,
|
||||
state.selectedPlugins[PluginAbility.METADATA]
|
||||
state.selectedPlugins[PluginAbility.METADATA],
|
||||
logoPaths,
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.AUDIO,
|
||||
snapshot.audioPlugins,
|
||||
state.selectedPlugins[PluginAbility.AUDIO]
|
||||
state.selectedPlugins[PluginAbility.AUDIO],
|
||||
logoPaths,
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.LYRICS,
|
||||
snapshot.lyricsPlugins,
|
||||
state.selectedPlugins[PluginAbility.LYRICS]
|
||||
state.selectedPlugins[PluginAbility.LYRICS],
|
||||
logoPaths,
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.SCROBBLE,
|
||||
snapshot.scrobblePlugins,
|
||||
state.selectedPlugins[PluginAbility.SCROBBLE]
|
||||
state.selectedPlugins[PluginAbility.SCROBBLE],
|
||||
logoPaths,
|
||||
),
|
||||
),
|
||||
pendingPlugin = snapshot.pendingPlugin,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -39,10 +39,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import okio.Path
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
@ -65,10 +61,6 @@ import spotube.composeapp.generated.resources.plugin_action_logout
|
||||
import spotube.composeapp.generated.resources.plugin_state_active
|
||||
import spotube.composeapp.generated.resources.plugin_state_builtin
|
||||
import spotube.composeapp.generated.resources.plugin_version_label
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
|
||||
|
||||
@Composable
|
||||
internal fun PluginCard(
|
||||
@ -96,17 +88,12 @@ internal fun PluginCard(
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
) {
|
||||
if (logoPath != null) {
|
||||
val platformContext = LocalPlatformContext.current
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(platformContext)
|
||||
.data(logoPath.toString())
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
PluginLogo(
|
||||
plugin = plugin,
|
||||
logoPath = logoPath,
|
||||
contentDescription = plugin.name,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
fallback = {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Iconsax.IconsaxBox,
|
||||
@ -115,7 +102,8 @@ internal fun PluginCard(
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Text content
|
||||
@ -302,12 +290,3 @@ internal fun PluginCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginAbility.displayLabel(): String {
|
||||
return when (this) {
|
||||
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
|
||||
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
|
||||
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
|
||||
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -39,10 +39,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import okio.Path
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
@ -100,24 +96,20 @@ fun PluginPermissionDialog(
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (logoPath != null) {
|
||||
val platformContext = LocalPlatformContext.current
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(platformContext)
|
||||
.data(logoPath.toString())
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
PluginLogo(
|
||||
plugin = pluginInfo,
|
||||
logoPath = logoPath,
|
||||
contentDescription = pluginInfo.name,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
fallback = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxBoxAdd,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
|
||||
@ -59,13 +59,6 @@ import spotube.composeapp.generated.resources.settings_section_jam
|
||||
fun JamSettingsScreen(
|
||||
viewModel: JamSettingsViewModel = koinViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val broker = uiState.settings.jamBroker
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
@ -73,10 +66,32 @@ fun JamSettingsScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
JamSettingsContent(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Group Jam settings UI without its own [Scaffold] / app bar, so it can be embedded
|
||||
* as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen].
|
||||
*/
|
||||
@Composable
|
||||
internal fun JamSettingsContent(
|
||||
viewModel: JamSettingsViewModel = koinViewModel(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val broker = uiState.settings.jamBroker
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
modifier = modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@ -271,4 +286,3 @@ fun JamSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
package dev.krtirtho.spotube.modules.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -37,6 +38,7 @@ import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import spotube.composeapp.generated.resources.*
|
||||
import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
|
||||
@ -52,17 +54,37 @@ import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
/**
|
||||
* Minimum width of the settings content area required to show the settings as an
|
||||
* overlay dialog instead of a full-screen destination.
|
||||
*/
|
||||
private val SettingsDialogMinWidth = 720.dp
|
||||
|
||||
/**
|
||||
* On large screens settings is presented as a modal overlay so it does not sit next to
|
||||
* the main navigation sidebar. On smaller screens it stays a full-screen destination.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
val navigatorCommands: NavigationCommands = koinInject()
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
if (maxWidth >= SettingsDialogMinWidth) {
|
||||
SettingsDialog(
|
||||
settingsViewModel = settingsViewModel,
|
||||
onDismiss = { navigatorCommands.pop(Routes.Settings) },
|
||||
)
|
||||
} else {
|
||||
CompactSettingsScreen(settingsViewModel = settingsViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
val navigatorCommands = koinInject<NavigationCommands>()
|
||||
val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
|
||||
val platformType = remember { getPlatform().type }
|
||||
val isDesktopPlatform = platformType == PlatformType.Windows ||
|
||||
platformType == PlatformType.Linux ||
|
||||
platformType == PlatformType.MacOS
|
||||
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
@ -79,11 +101,32 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
CompactSettingsContent(
|
||||
settings = settingsState,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigatorCommands = navigatorCommands,
|
||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||
contentPadding = contentPadding,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactSettingsContent(
|
||||
settings: UserSettings?,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
navigatorCommands: NavigationCommands,
|
||||
requestLocalNetworkPermission: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val platformType = remember { getPlatform().type }
|
||||
val isDesktopPlatform = platformType == PlatformType.Windows ||
|
||||
platformType == PlatformType.Linux ||
|
||||
platformType == PlatformType.MacOS
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 1280.dp)
|
||||
@ -93,52 +136,44 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
pluginsSection(
|
||||
navigatorCommands = navigatorCommands,
|
||||
)
|
||||
if (settingsState != null) {
|
||||
if (settings != null) {
|
||||
languageRegionSection(
|
||||
settings = settingsState!!,
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
if (settingsState != null)
|
||||
appearanceSection(
|
||||
settings = settingsState!!,
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
if (settingsState != null)
|
||||
playbackSection(
|
||||
settings = settingsState!!,
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigatorCommands = navigatorCommands,
|
||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||
)
|
||||
if (settingsState != null)
|
||||
jamSection(
|
||||
settings = settingsState!!,
|
||||
settings = settings,
|
||||
navigationCommands = navigatorCommands,
|
||||
)
|
||||
if (settingsState != null)
|
||||
cacheSection(
|
||||
settings = settingsState!!,
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
if (settingsState != null)
|
||||
downloadsSection(
|
||||
settings = settingsState!!,
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
if (isDesktopPlatform && settingsState != null) {
|
||||
if (isDesktopPlatform) {
|
||||
desktopSection(
|
||||
settings = settingsState!!,
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
if (settingsState != null)
|
||||
updatesSection(
|
||||
settings = settingsState!!,
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -67,8 +67,11 @@ import spotube.composeapp.generated.resources.*
|
||||
internal fun LazyListScope.appearanceSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_appearance)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -42,8 +42,11 @@ import org.jetbrains.compose.resources.stringResource
|
||||
internal fun LazyListScope.cacheSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_caching)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -30,8 +30,11 @@ import org.jetbrains.compose.resources.stringResource
|
||||
internal fun LazyListScope.desktopSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_desktop)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -79,6 +79,7 @@ import spotube.composeapp.generated.resources.settings_subtitle_current
|
||||
internal fun LazyListScope.downloadsSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets)
|
||||
val downloadQualities = availableAudioQualities(
|
||||
@ -86,7 +87,9 @@ internal fun LazyListScope.downloadsSection(
|
||||
current = settings.downloadMusicQuality,
|
||||
)
|
||||
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_downloads)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -35,8 +35,11 @@ import org.jetbrains.compose.resources.stringResource
|
||||
internal fun LazyListScope.languageRegionSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_language_region)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -20,6 +20,7 @@ package dev.krtirtho.spotube.modules.settings.sections
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.Cast
|
||||
@ -51,6 +52,8 @@ internal fun LazyListScope.playbackSection(
|
||||
settingsViewModel: SettingsViewModel,
|
||||
navigatorCommands: NavigationCommands,
|
||||
requestLocalNetworkPermission: () -> Unit,
|
||||
includeBlacklist: Boolean = true,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
|
||||
val streamingQualities = availableAudioQualities(
|
||||
@ -58,10 +61,8 @@ internal fun LazyListScope.playbackSection(
|
||||
current = settings.streamingMusicQuality,
|
||||
)
|
||||
|
||||
settingsSectionHeader(Res.string.settings_section_playback)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
val cards: List<@Composable () -> Unit> = buildList {
|
||||
add {
|
||||
SelectionSettingCard(
|
||||
title = stringResource(Res.string.settings_streaming_format_title),
|
||||
subtitle = stringResource(
|
||||
@ -83,8 +84,8 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
}
|
||||
add {
|
||||
SelectionSettingCard(
|
||||
title = stringResource(Res.string.settings_streaming_quality_title),
|
||||
subtitle = stringResource(
|
||||
@ -103,8 +104,8 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_enable_endless_playback_title),
|
||||
subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
|
||||
@ -121,8 +122,8 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_enable_connect_title),
|
||||
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
|
||||
@ -136,8 +137,8 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_allow_remote_control_title),
|
||||
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
|
||||
@ -159,8 +160,8 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
}
|
||||
add {
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_remote_device_name_title),
|
||||
subtitle = stringResource(
|
||||
@ -181,8 +182,8 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
}
|
||||
add {
|
||||
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
||||
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
||||
|
||||
@ -213,8 +214,9 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
}
|
||||
if (includeBlacklist) {
|
||||
add {
|
||||
SettingCardItem(
|
||||
title = stringResource(Res.string.settings_blacklist_title),
|
||||
subtitle = stringResource(Res.string.settings_blacklist_subtitle),
|
||||
@ -235,8 +237,12 @@ internal fun LazyListScope.playbackSection(
|
||||
navigatorCommands.navigateTo(Routes.Blacklist)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_playback)
|
||||
}
|
||||
settingsSectionCard(items = cards)
|
||||
}
|
||||
|
||||
@ -32,8 +32,11 @@ import spotube.composeapp.generated.resources.settings_updates_auto_check_title
|
||||
internal fun LazyListScope.updatesSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_updates)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf {
|
||||
SwitchSettingCard(
|
||||
|
||||
@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
@ -103,6 +104,23 @@ internal fun LazyListScope.settingsSectionHeader(title: StringResource) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page-level title for a settings destination (used by the large-screen sidebar
|
||||
* layout, where each section is its own page rather than one entry in a long list).
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsPageTitle(title: StringResource) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsItemIcon(
|
||||
imageVector: ImageVector,
|
||||
|
||||
@ -24,23 +24,18 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -51,7 +46,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -64,11 +58,8 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.copyPadding
|
||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||
import dev.krtirtho.spotube.core.ui.component.SidebarItem
|
||||
import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel
|
||||
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||
@ -150,20 +141,12 @@ fun AppSidebar(
|
||||
items(sidebarTabs.size) { index ->
|
||||
when (val tab = sidebarTabs[index]) {
|
||||
is NavigationItem.Group<*> -> {
|
||||
androidx.compose.animation.AnimatedVisibility(visible = expanded) {
|
||||
Text(
|
||||
text = tab.title.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
SidebarSectionLabel(text = tab.title, expanded = expanded)
|
||||
tab.items.forEach {
|
||||
val selected = navigationState.topLevelRoute == it.route
|
||||
SidebarItem(
|
||||
label = it.title,
|
||||
activeIcon = it.icon,
|
||||
icon = it.icon,
|
||||
onClick = {
|
||||
if (it.route == Routes.Library) {
|
||||
libraryState.currentTab.value =
|
||||
@ -173,8 +156,17 @@ fun AppSidebar(
|
||||
},
|
||||
selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected,
|
||||
expanded = expanded,
|
||||
showDownloadBadge = it.route == Routes.Library
|
||||
badge = if (it.route == Routes.Library
|
||||
&& it.data == LibraryTab.Downloads
|
||||
) {
|
||||
{
|
||||
DownloadBadgeIndicator(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -184,7 +176,7 @@ fun AppSidebar(
|
||||
val selected = navigationState.topLevelRoute == tab.route
|
||||
SidebarItem(
|
||||
label = tab.title,
|
||||
activeIcon = tab.icon,
|
||||
icon = tab.icon,
|
||||
onClick = {
|
||||
navigator.navigate(tab.route)
|
||||
},
|
||||
@ -206,7 +198,7 @@ fun AppSidebar(
|
||||
HorizontalDivider()
|
||||
SidebarItem(
|
||||
label = "Settings",
|
||||
activeIcon = Iconsax.IconsaxSetting2,
|
||||
icon = Iconsax.IconsaxSetting2,
|
||||
onClick = {
|
||||
navigator.navigate(Routes.Settings)
|
||||
},
|
||||
@ -216,75 +208,3 @@ fun AppSidebar(
|
||||
Spacer(modifier = Modifier.height(120.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SidebarItem(
|
||||
label: String,
|
||||
activeIcon: ImageVector,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
onClick: () -> Unit,
|
||||
showDownloadBadge: Boolean = false,
|
||||
) {
|
||||
val itemContent: @Composable RowScope.() -> Unit = {
|
||||
Box(modifier = Modifier.size(16.dp)) {
|
||||
Icon(
|
||||
imageVector = activeIcon,
|
||||
contentDescription = label,
|
||||
tint = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (showDownloadBadge) {
|
||||
DownloadBadgeIndicator(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
style = LocalTextStyle.current.copy(
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
|
||||
}
|
||||
|
||||
val buttonModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
val contentPadding = PaddingValues(horizontal = 5.dp)
|
||||
|
||||
if (selected) {
|
||||
SecondaryButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
theme = LocalBaseUITheme.current.buttons.secondary
|
||||
.copyPadding(contentPadding)
|
||||
.copyShape(RoundedCornerShape(5.dp)),
|
||||
content = itemContent,
|
||||
)
|
||||
} else {
|
||||
OutlineButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
theme = LocalBaseUITheme.current.buttons.outline
|
||||
.copyPadding(contentPadding)
|
||||
.copyShape(RoundedCornerShape(5.dp)),
|
||||
hoverOnly = true,
|
||||
content = itemContent,
|
||||
)
|
||||
}
|
||||
}
|
||||