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