diff --git a/AGENTS.md b/AGENTS.md
index f9339626..3f6ee2d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -29,7 +29,10 @@
- `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system.
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
-## Desktop JVM specifics
+## UI component patterns
+- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
+- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
+- Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain.
- JavaFX is required; `--add-opens` flags in `compose.desktop.application.jvmArgs` must be preserved: `javafx.graphics/javafx.scene`, `javafx.graphics/com.sun.javafx.sg.prism`, `javafx.graphics/com.sun.javafx.scene`, `javafx.web/com.sun.webkit`, `javafx.media/com.sun.media.jfxmedia`, `javafx.media/com.sun.media.jfxmedia.events`.
- JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via `System.getProperty("os.name")`.
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 73baf533..e582d8a0 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -126,6 +126,7 @@
%1$s plugin
Clear
+ Configure
Plugin Manager
Please enter a URL
URL must start with http:// or https://
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt
new file mode 100644
index 00000000..33d0ca4b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.krtirtho.spotube.core.ui.base.ThemedDialog
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AdaptiveDialogBottomSheet(
+ onDismiss: () -> Unit,
+ title: @Composable (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+ breakpointDp: Float = 600f,
+ actions: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ val adaptiveInfo = currentWindowAdaptiveInfo()
+ val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
+
+ if (isLargeScreen) {
+ ThemedDialog(
+ onDismissRequest = onDismiss,
+ title = title,
+ actions = actions,
+ modifier = modifier,
+ ) {
+ content()
+ }
+ } else {
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ dragHandle = null,
+ modifier = modifier,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ title?.let {
+ it()
+ Spacer(Modifier.height(12.dp))
+ }
+ content()
+ actions?.let {
+ Spacer(Modifier.height(8.dp))
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+ Spacer(Modifier.height(8.dp))
+ it()
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ }
+ }
+}
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 5de3aa80..51670a4a 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
@@ -26,9 +26,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -55,22 +57,30 @@ import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.ui.base.OutlineButton
+import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
+import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
+import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
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.core.webview.WebViewController
import dev.krtirtho.spotube.getPlatform
-import dev.krtirtho.spotube.modules.plugin.components.InstallSection
+
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
+import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
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.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
@@ -83,6 +93,9 @@ import org.koin.compose.koinInject
import kotlinx.coroutines.flow.StateFlow
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_empty_subtitle
+import spotube.composeapp.generated.resources.plugin_action_download
+import spotube.composeapp.generated.resources.plugin_action_install_from_file
+import spotube.composeapp.generated.resources.plugin_configure_title
import spotube.composeapp.generated.resources.plugin_empty_title
import spotube.composeapp.generated.resources.plugin_error_download_failed
import spotube.composeapp.generated.resources.plugin_error_enter_url
@@ -90,7 +103,9 @@ import spotube.composeapp.generated.resources.plugin_error_url_scheme
import spotube.composeapp.generated.resources.plugin_installed_count
import spotube.composeapp.generated.resources.plugin_installed_plural
import spotube.composeapp.generated.resources.plugin_installed_singular
+import spotube.composeapp.generated.resources.plugin_install_section_title
import spotube.composeapp.generated.resources.plugin_screen_title
+import spotube.composeapp.generated.resources.plugin_url_placeholder
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_ability_audio
@@ -120,6 +135,7 @@ fun PluginScreen(
var urlInput by remember { mutableStateOf("") }
var urlError by remember { mutableStateOf(null) }
var isLoadingUrl by remember { mutableStateOf(false) }
+ var showInstallSheet by remember { mutableStateOf(false) }
val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
@@ -175,6 +191,90 @@ fun PluginScreen(
)
}
+ if (showInstallSheet) {
+ AdaptiveDialogBottomSheet(
+ onDismiss = { showInstallSheet = false },
+ title = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Iconsax.IconsaxImportArrow2Bulk,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ stringResource(Res.string.plugin_install_section_title),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ },
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Top,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ TextField(
+ value = urlInput,
+ onValueChange = { urlInput = it; urlError = null },
+ modifier = Modifier.weight(1f),
+ placeholder = {
+ Text(
+ stringResource(Res.string.plugin_url_placeholder),
+ style = MaterialTheme.typography.bodySmall
+ )
+ },
+ leadingIcon = {
+ Icon(
+ Iconsax.IconsaxLink,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ },
+ isError = urlError != null,
+ singleLine = true,
+ )
+ SecondaryIconButton(
+ onClick = { submitUrl() },
+ enabled = !isLoadingUrl,
+ ) {
+ if (isLoadingUrl) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Icon(
+ Iconsax.IconsaxImportArrow2Bulk,
+ contentDescription = stringResource(Res.string.plugin_action_download),
+ )
+ }
+ }
+ }
+
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+
+ OutlineButton(
+ onClick = { launcher.launch() },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ Iconsax.IconsaxExportArrowBulk,
+ contentDescription = stringResource(Res.string.plugin_action_install_from_file)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.plugin_action_install_from_file))
+ }
+ }
+ }
+ }
+
Scaffold(
topBar = {
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
@@ -205,16 +305,28 @@ fun PluginScreen(
verticalArrangement = Arrangement.spacedBy(8.dp)
)
{
- // ── Install section ───────────────────────────────────
+ // ── Configure header ──────────────────────────────
item {
- InstallSection(
- urlInput = urlInput,
- onUrlChange = { urlInput = it; urlError = null },
- urlError = urlError,
- isLoadingUrl = isLoadingUrl,
- onSubmitUrl = { submitUrl() },
- onPickFile = { launcher.launch() }
- )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ stringResource(Res.string.plugin_configure_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ PrimaryButton(onClick = { showInstallSheet = true }) {
+ Icon(
+ Iconsax.IconsaxAdd,
+ contentDescription = "Install a plugin",
+ )
+ Text(stringResource(Res.string.plugin_install_section_title))
+ }
+ }
}
// ── Default ability plugin selectors ─────────────────
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt
deleted file mode 100644
index 0f97928b..00000000
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt
+++ /dev/null
@@ -1,151 +0,0 @@
-/*
- * 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.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-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.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
-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.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import dev.krtirtho.spotube.core.ui.base.Card
-import dev.krtirtho.spotube.core.ui.base.TextField
-import dev.krtirtho.spotube.core.ui.base.OutlineButton
-import dev.krtirtho.spotube.core.ui.base.PrimaryButton
-import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
-import dev.krtirtho.spotube.resources.iconsax.Iconsax
-import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
-import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
-import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
-import org.jetbrains.compose.resources.stringResource
-import spotube.composeapp.generated.resources.Res
-import spotube.composeapp.generated.resources.plugin_action_download
-import spotube.composeapp.generated.resources.plugin_action_install_from_file
-import spotube.composeapp.generated.resources.plugin_install_section_title
-import spotube.composeapp.generated.resources.plugin_url_placeholder
-
-@Composable
-internal fun InstallSection(
- urlInput: String,
- onUrlChange: (String) -> Unit,
- urlError: String?,
- isLoadingUrl: Boolean,
- onSubmitUrl: () -> Unit,
- onPickFile: () -> Unit,
-) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- ) {
- Column(
- modifier = Modifier.padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Icon(
- Iconsax.IconsaxImportArrow2Bulk,
- contentDescription = null,
- modifier = Modifier.size(16.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- Text(
- stringResource(Res.string.plugin_install_section_title),
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.SemiBold
- )
- }
-
- // URL row
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.Top,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- TextField(
- value = urlInput,
- onValueChange = onUrlChange,
- modifier = Modifier.weight(1f),
- placeholder = {
- Text(
- stringResource(Res.string.plugin_url_placeholder),
- style = MaterialTheme.typography.bodySmall
- )
- },
- leadingIcon = {
- Icon(
- Iconsax.IconsaxLink,
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- },
- isError = urlError != null,
-// supportingText = urlError?.let { { Text(it) } },
-// shape = RoundedCornerShape(10.dp),
- singleLine = true,
- )
- SecondaryIconButton(
- onClick = onSubmitUrl,
- enabled = !isLoadingUrl,
- ) {
- if (isLoadingUrl) {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onPrimary
- )
- } else {
- Icon(
- Iconsax.IconsaxImportArrow2Bulk,
- contentDescription = stringResource(Res.string.plugin_action_download),
- )
- }
- }
- }
-
- HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
-
- // File picker
- OutlineButton(
- onClick = onPickFile,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Icon(
- Iconsax.IconsaxExportArrowBulk,
- contentDescription = stringResource(Res.string.plugin_action_install_from_file)
- )
- Spacer(Modifier.width(8.dp))
- Text(stringResource(Res.string.plugin_action_install_from_file))
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt
new file mode 100644
index 00000000..520ebcc4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt
@@ -0,0 +1,61 @@
+package dev.krtirtho.spotube.resources.iconsax
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.PathData
+import androidx.compose.ui.graphics.vector.group
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+val Iconsax.IconsaxAdd: ImageVector
+ get() {
+ if (_IncosaxAdd != null) {
+ return _IncosaxAdd!!
+ }
+ _IncosaxAdd = ImageVector.Builder(
+ name = "IncosaxAdd",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f
+ ).apply {
+ group(
+ clipPathData = PathData {
+ moveTo(0f, 0f)
+ horizontalLineToRelative(24f)
+ verticalLineToRelative(24f)
+ horizontalLineToRelative(-24f)
+ close()
+ }
+ ) {
+ path(
+ fillAlpha = 0.4f,
+ stroke = SolidColor(Color.White),
+ strokeAlpha = 0.4f,
+ strokeLineWidth = 1.5f,
+ strokeLineCap = StrokeCap.Round,
+ strokeLineJoin = StrokeJoin.Round
+ ) {
+ moveTo(6f, 12f)
+ horizontalLineTo(18f)
+ }
+ path(
+ stroke = SolidColor(Color.White),
+ strokeLineWidth = 1.5f,
+ strokeLineCap = StrokeCap.Round,
+ strokeLineJoin = StrokeJoin.Round
+ ) {
+ moveTo(12f, 18f)
+ verticalLineTo(6f)
+ }
+ }
+ }.build()
+
+ return _IncosaxAdd!!
+ }
+
+@Suppress("ObjectPropertyName")
+private var _IncosaxAdd: ImageVector? = null