diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt
index 9fabbdfb..50bef4af 100644
--- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt
@@ -1,24 +1,36 @@
-/*
- * 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.webview
-
-import io.github.kdroidfilter.webview.web.WebViewState
-
-actual fun platformWebviewConfig(webView: WebViewState) {
- webView.webView?.nativeWebView?.settings?.domStorageEnabled = true
+/*
+ * 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.webview
+
+import android.webkit.CookieManager
+import android.webkit.WebStorage
+import android.webkit.WebView
+import io.github.kdroidfilter.webview.web.WebViewState
+
+actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
+ val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return
+ nativeWebView.settings.domStorageEnabled = true
+}
+
+actual suspend fun platformClearWebviewData(pluginId: String?) {
+ if (pluginId == null) return
+
+ CookieManager.getInstance().removeAllCookies(null)
+ CookieManager.getInstance().flush()
+ WebStorage.getInstance().deleteAllData()
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt
index 98870847..1dc64f24 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt
@@ -1,157 +1,164 @@
-/*
- * 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.webview
-
-import io.github.kdroidfilter.webview.web.WebContent
-import io.github.kdroidfilter.webview.web.WebViewNavigator
-import io.github.kdroidfilter.webview.cookie.CookieManager
-import dev.krtirtho.plugin_interfaces.host_apis.Cookie
-import dev.krtirtho.spotube.core.di.injectLogger
-import dev.krtirtho.spotube.core.navigation.NavigationCommands
-import dev.krtirtho.spotube.core.navigation.Routes
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
-import org.koin.core.component.KoinComponent
-
-@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
-class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
- private val logger by injectLogger()
- private var cookieManager: CookieManager? = null
- private val urlFlow = MutableStateFlow("")
- private val webViewCreated = MutableSharedFlow(replay = 1)
-
- suspend fun getCookies(url: String): List {
- if (cookieManager == null) {
- logger.w { "CookieManager is not initialized. Returning empty cookie list." }
- return emptyList()
- }
- val cookies = cookieManager!!.getCookies(url)
- val cookieList = mutableListOf()
- cookies.forEach {
- cookieList.add(
- Cookie(
- name = it.name,
- value = it.value,
- domain = it.domain ?: "",
- path = it.path,
- expiresAt = it.expiresDate,
- secure = it.isSecure ?: false,
- httpOnly = it.isHttpOnly ?: false
- )
- )
- }
- return cookieList
- }
-
- private var content: String? = null
- private var isHtmlContent: Boolean = false
- fun getContent(): String? = content
- fun getWebContent(additionalHttpHeaders: Map = emptyMap()): WebContent {
- if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.")
- if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html")
- return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders)
- }
-
- var webViewNavigator: WebViewNavigator? = null
-
- fun emitUrlChange(url: String) {
- urlFlow.value = url
- }
-
- fun emitWebViewCreated() {
- webViewCreated.tryEmit(Unit)
- }
-
- fun setCookieManager(cookieManager: CookieManager) {
- this.cookieManager = cookieManager
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- fun closeWebview() {
- cookieManager = null
- content = null
- isHtmlContent = false
- webViewNavigator = null
- navigationCommands.pop(Routes.WebView)
- urlFlow.value = ""
- webViewCreated.resetReplayCache()
- _postMessagesFlow.resetReplayCache()
- }
-
- fun dispose() {
- cookieManager = null
- content = null
- isHtmlContent = false
- webViewNavigator = null
- }
-
- fun navigateTo(url: String) {
- if (this.content != null) {
- throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
- }
- this.content = url
- this.isHtmlContent = false
- navigationCommands.navigateTo(Routes.WebView)
- }
-
- fun navigateToHTML(html: String) {
- if (this.content != null) {
- throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
- }
- this.content = html
- this.isHtmlContent = true
- navigationCommands.navigateTo(Routes.WebView)
- }
-
- suspend fun evaluateJavascript(jsCode: String): String? {
- if (webViewNavigator == null) {
- throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
- }
- val completer = CompletableDeferred()
- try {
- webViewNavigator?.evaluateJavaScript(jsCode) { result ->
- completer.complete(result)
- }
- } catch (e: Exception) {
- completer.completeExceptionally(e)
- throw e
- }
- return completer.await()
- }
-
- suspend fun clearData() {
- cookieManager?.removeAllCookies()
- cookieManager = null
- content = null
- isHtmlContent = false
- webViewNavigator = null
- }
-
- val urlChangedFlow = urlFlow.asStateFlow()
- val webviewCreatedFlow = webViewCreated.asSharedFlow()
- private val _postMessagesFlow = MutableSharedFlow(replay = 1)
- fun emitPostMessage(message: String) {
- _postMessagesFlow.tryEmit(message)
- }
-
- val postMessagesFlow = _postMessagesFlow.asSharedFlow()
+/*
+ * 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.webview
+
+import io.github.kdroidfilter.webview.web.WebContent
+import io.github.kdroidfilter.webview.web.WebViewNavigator
+import io.github.kdroidfilter.webview.cookie.CookieManager
+import dev.krtirtho.plugin_interfaces.host_apis.Cookie
+import dev.krtirtho.spotube.core.di.injectLogger
+import dev.krtirtho.spotube.core.navigation.NavigationCommands
+import dev.krtirtho.spotube.core.navigation.Routes
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.koin.core.component.KoinComponent
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
+ private val logger by injectLogger()
+ private var cookieManager: CookieManager? = null
+ private val urlFlow = MutableStateFlow("")
+ private val webViewCreated = MutableSharedFlow(replay = 1)
+ var currentPluginId: String? = null
+ private set
+
+ suspend fun getCookies(url: String): List {
+ if (cookieManager == null) {
+ logger.w { "CookieManager is not initialized. Returning empty cookie list." }
+ return emptyList()
+ }
+ val cookies = cookieManager!!.getCookies(url)
+ val cookieList = mutableListOf()
+ cookies.forEach {
+ cookieList.add(
+ Cookie(
+ name = it.name,
+ value = it.value,
+ domain = it.domain ?: "",
+ path = it.path,
+ expiresAt = it.expiresDate,
+ secure = it.isSecure ?: false,
+ httpOnly = it.isHttpOnly ?: false
+ )
+ )
+ }
+ return cookieList
+ }
+
+ private var content: String? = null
+ private var isHtmlContent: Boolean = false
+ fun getContent(): String? = content
+ fun getWebContent(additionalHttpHeaders: Map = emptyMap()): WebContent {
+ if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.")
+ if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html")
+ return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders)
+ }
+
+ var webViewNavigator: WebViewNavigator? = null
+
+ fun emitUrlChange(url: String) {
+ urlFlow.value = url
+ }
+
+ fun emitWebViewCreated() {
+ webViewCreated.tryEmit(Unit)
+ }
+
+ fun setCookieManager(cookieManager: CookieManager) {
+ this.cookieManager = cookieManager
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun closeWebview() {
+ cookieManager = null
+ content = null
+ isHtmlContent = false
+ webViewNavigator = null
+ navigationCommands.pop(Routes.WebView)
+ urlFlow.value = ""
+ webViewCreated.resetReplayCache()
+ _postMessagesFlow.resetReplayCache()
+ }
+
+ fun dispose() {
+ cookieManager = null
+ content = null
+ isHtmlContent = false
+ webViewNavigator = null
+ }
+
+ fun navigateTo(url: String, pluginId: String) {
+ if (this.content != null) {
+ throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
+ }
+ this.currentPluginId = pluginId
+ this.content = url
+ this.isHtmlContent = false
+ navigationCommands.navigateTo(Routes.WebView)
+ }
+
+ fun navigateToHTML(html: String, pluginId: String) {
+ if (this.content != null) {
+ throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
+ }
+ this.currentPluginId = pluginId
+ this.content = html
+ this.isHtmlContent = true
+ navigationCommands.navigateTo(Routes.WebView)
+ }
+
+ suspend fun evaluateJavascript(jsCode: String): String? {
+ if (webViewNavigator == null) {
+ throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
+ }
+ val completer = CompletableDeferred()
+ try {
+ webViewNavigator?.evaluateJavaScript(jsCode) { result ->
+ completer.complete(result)
+ }
+ } catch (e: Exception) {
+ completer.completeExceptionally(e)
+ throw e
+ }
+ return completer.await()
+ }
+
+ suspend fun clearData(pluginId: String? = null) {
+ cookieManager?.removeAllCookies()
+ val targetPluginId = pluginId ?: currentPluginId
+ platformClearWebviewData(targetPluginId)
+ cookieManager = null
+ content = null
+ isHtmlContent = false
+ webViewNavigator = null
+ currentPluginId = null
+ }
+
+ val urlChangedFlow = urlFlow.asStateFlow()
+ val webviewCreatedFlow = webViewCreated.asSharedFlow()
+ private val _postMessagesFlow = MutableSharedFlow(replay = 1)
+ fun emitPostMessage(message: String) {
+ _postMessagesFlow.tryEmit(message)
+ }
+
+ val postMessagesFlow = _postMessagesFlow.asSharedFlow()
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt
index 66fc7a0b..39d345f1 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt
@@ -1,22 +1,24 @@
-/*
- * 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.webview
-
-import io.github.kdroidfilter.webview.web.WebViewState
-
-expect fun platformWebviewConfig(webView: WebViewState)
+/*
+ * 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.webview
+
+import io.github.kdroidfilter.webview.web.WebViewState
+
+expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)
+
+expect suspend fun platformClearWebviewData(pluginId: String?)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt
index 5c10a031..7aeba292 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt
@@ -1,228 +1,222 @@
-/*
- * 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.webview
-
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.WindowInsets
-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.statusBars
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.foundation.layout.wrapContentHeight
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
-import io.github.kdroidfilter.webview.jsbridge.JsMessage
-import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
-import io.github.kdroidfilter.webview.web.WebView
-import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
-import io.github.kdroidfilter.webview.web.WebViewState
-import io.github.kdroidfilter.webview.web.WebViewNavigator
-import compose.icons.FeatherIcons
-import compose.icons.feathericons.ChevronLeft
-import compose.icons.feathericons.ChevronRight
-import compose.icons.feathericons.X
-import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
-import kotlinx.coroutines.launch
-
-class PostMessageHandler(
- private val onMessageReceived: (String) -> Unit = {}
-) : IJsMessageHandler {
- override fun methodName(): String {
- return "sendMessage"
- }
-
- override fun handle(
- message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
- ) {
- onMessageReceived(message.params)
- callback(message.params)
- }
-}
-
-@Composable
-fun PlatformWebViewScreen(webViewController: WebViewController) {
- if (webViewController.getContent() == null) {
- // This should never happen, but just in case
- Text("No URL to load")
- return
- }
-
- val state = remember {
- WebViewState(
- webViewController.getWebContent(
- additionalHttpHeaders = mapOf(
- "User-Agent" to UserAgents.random()
- )
- )
- )
- }.apply {
- this.content = webViewController.getWebContent()
- platformWebviewConfig(this)
- }
-
- val navigator = rememberWebViewNavigator()
- val webViewBridge = rememberWebViewJsBridge(navigator)
-
- val bridgeBootstrapScript = remember {
- """
- (function() {
- if (typeof window.sendMessage !== "function") {
- window.sendMessage = function(message) {
- if (typeof message !== "string") {
- throw new TypeError("[window.sendMessage] Message must be a string");
- }
- window.kmpJsBridge.callNative("sendMessage", message);
- };
- }
-
- if (!window.bridgeReady) {
- const event = new CustomEvent("onBridgeReady");
- window.dispatchEvent(event);
- window.bridgeReady = true;
- }
- })();
- """.trimIndent()
- }
-
- LaunchedEffect(state) {
- snapshotFlow { state.lastLoadedUrl }.collect { url ->
- if (url != null) {
- webViewController.emitUrlChange(url)
- navigator.evaluateJavaScript(bridgeBootstrapScript)
- webViewController.emitWebViewCreated()
- }
- }
- }
-
- LaunchedEffect(state.cookieManager, navigator) {
- webViewController.setCookieManager(cookieManager = state.cookieManager)
- webViewController.webViewNavigator = navigator
- }
-
- LaunchedEffect(webViewBridge) {
- webViewBridge.register(PostMessageHandler { message ->
- webViewController.emitPostMessage(message)
- })
- }
-
- DisposableEffect(Unit) {
- onDispose {
- webViewController.dispose()
- }
- }
-
- Scaffold(
- contentWindowInsets = WindowInsets.statusBars,
- topBar = {
- Row(
- modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Start,
- modifier = Modifier.height(56.dp)
- ) {
- IconButton(
- onClick = {
- navigator.navigateBack()
- }, enabled = navigator.canGoBack
- ) {
- Icon(
- FeatherIcons.ChevronLeft,
- contentDescription = "Go back to browser history"
- )
- }
- IconButton(
- onClick = {
- navigator.navigateForward()
- }, enabled = navigator.canGoForward
- ) {
- Icon(
- FeatherIcons.ChevronRight,
- contentDescription = "Go forward to browser history"
- )
- }
- }
- Surface(
- modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
- shape = RoundedCornerShape(18.dp),
- color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
- border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
- ) {
- BasicTextField(
- value = state.lastLoadedUrl ?: "",
- onValueChange = {}, // Read-only
- readOnly = true,
- singleLine = true,
- textStyle = MaterialTheme.typography.bodyMedium.copy(
- color = MaterialTheme.colorScheme.onSurface,
- textAlign = TextAlign.Start
- ),
- modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
- .wrapContentHeight(Alignment.CenterVertically)
- )
- }
- IconButton(
- onClick = {
- webViewController.closeWebview()
- }) {
- Icon(FeatherIcons.X, contentDescription = "Close WebView")
- }
- }
- }) { innerPadding ->
- WebView(
- state = state,
- modifier = Modifier.padding(innerPadding).fillMaxSize(),
- navigator = navigator,
- webViewJsBridge = webViewBridge,
- onCreated = { webView ->
- navigator.evaluateJavaScript(bridgeBootstrapScript)
-
- },
- factory = null
- )
- }
+/*
+ * 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.webview
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+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.statusBars
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import compose.icons.FeatherIcons
+import compose.icons.feathericons.ChevronLeft
+import compose.icons.feathericons.ChevronRight
+import compose.icons.feathericons.X
+import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
+import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
+import io.github.kdroidfilter.webview.jsbridge.JsMessage
+import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
+import io.github.kdroidfilter.webview.web.WebView
+import io.github.kdroidfilter.webview.web.WebViewNavigator
+import io.github.kdroidfilter.webview.web.WebViewState
+import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
+
+class PostMessageHandler(
+ private val onMessageReceived: (String) -> Unit = {}
+) : IJsMessageHandler {
+ override fun methodName(): String {
+ return "sendMessage"
+ }
+
+ override fun handle(
+ message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
+ ) {
+ onMessageReceived(message.params)
+ callback(message.params)
+ }
+}
+
+@Composable
+fun PlatformWebViewScreen(webViewController: WebViewController) {
+ if (webViewController.getContent() == null) {
+ // This should never happen, but just in case
+ Text("No URL to load")
+ return
+ }
+
+ val state = remember {
+ WebViewState(
+ webViewController.getWebContent(
+ additionalHttpHeaders = mapOf(
+ "User-Agent" to UserAgents.random()
+ )
+ )
+ )
+ }.apply {
+ this.content = webViewController.getWebContent()
+ platformWebviewConfig(this, webViewController.currentPluginId)
+ }
+
+ val navigator = rememberWebViewNavigator()
+ val webViewBridge = rememberWebViewJsBridge(navigator)
+
+ val bridgeBootstrapScript = remember {
+ """
+ (function() {
+ if (typeof window.sendMessage !== "function") {
+ window.sendMessage = function(message) {
+ if (typeof message !== "string") {
+ throw new TypeError("[window.sendMessage] Message must be a string");
+ }
+ window.kmpJsBridge.callNative("sendMessage", message);
+ };
+ }
+
+ if (!window.bridgeReady) {
+ const event = new CustomEvent("onBridgeReady");
+ window.dispatchEvent(event);
+ window.bridgeReady = true;
+ }
+ })();
+ """.trimIndent()
+ }
+
+ LaunchedEffect(state) {
+ snapshotFlow { state.lastLoadedUrl }.collect { url ->
+ if (url != null) {
+ webViewController.emitUrlChange(url)
+ navigator.evaluateJavaScript(bridgeBootstrapScript)
+ webViewController.emitWebViewCreated()
+ }
+ }
+ }
+
+ LaunchedEffect(state.cookieManager, navigator) {
+ webViewController.setCookieManager(cookieManager = state.cookieManager)
+ webViewController.webViewNavigator = navigator
+ }
+
+ LaunchedEffect(webViewBridge) {
+ webViewBridge.register(PostMessageHandler { message ->
+ webViewController.emitPostMessage(message)
+ })
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ webViewController.dispose()
+ }
+ }
+
+ Scaffold(
+ contentWindowInsets = WindowInsets.statusBars,
+ topBar = {
+ Row(
+ modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start,
+ modifier = Modifier.height(56.dp)
+ ) {
+ IconButton(
+ onClick = {
+ navigator.navigateBack()
+ }, enabled = navigator.canGoBack
+ ) {
+ Icon(
+ FeatherIcons.ChevronLeft,
+ contentDescription = "Go back to browser history"
+ )
+ }
+ IconButton(
+ onClick = {
+ navigator.navigateForward()
+ }, enabled = navigator.canGoForward
+ ) {
+ Icon(
+ FeatherIcons.ChevronRight,
+ contentDescription = "Go forward to browser history"
+ )
+ }
+ }
+ Surface(
+ modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
+ shape = RoundedCornerShape(18.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
+ ) {
+ BasicTextField(
+ value = state.lastLoadedUrl ?: "",
+ onValueChange = {}, // Read-only
+ readOnly = true,
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyMedium.copy(
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Start
+ ),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
+ .wrapContentHeight(Alignment.CenterVertically)
+ )
+ }
+ IconButton(
+ onClick = {
+ webViewController.closeWebview()
+ }) {
+ Icon(FeatherIcons.X, contentDescription = "Close WebView")
+ }
+ }
+ }) { innerPadding ->
+ WebView(
+ state = state,
+ modifier = Modifier.padding(innerPadding).fillMaxSize(),
+ navigator = navigator,
+ webViewJsBridge = webViewBridge,
+ onCreated = { webView ->
+ navigator.evaluateJavaScript(bridgeBootstrapScript)
+
+ },
+ factory = null
+ )
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt
index 6233d83c..d055b64e 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt
@@ -1,345 +1,345 @@
-/*
- * 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.zipline
-
-import app.cash.zipline.Zipline
-import app.cash.zipline.ZiplineService
-import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
-import app.cash.zipline.loader.LoadResult
-import app.cash.zipline.loader.ManifestVerifier
-import app.cash.zipline.loader.ZiplineLoader
-import dev.krtirtho.plugin_interfaces.core.Initializer
-import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
-import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
-import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
-import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
-import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
-import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
-import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
-import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
-import dev.krtirtho.spotube.core.di.injectLogger
-import dev.krtirtho.spotube.core.webview.WebViewController
-import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
-import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
-import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
-import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
-import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
-import dev.krtirtho.spotube.modules.plugin.PluginAbility
-import dev.krtirtho.spotube.modules.plugin.PluginCapability
-import dev.krtirtho.spotube.modules.plugin.PluginEntry
-import io.ktor.http.URLBuilder
-import io.ktor.http.decodeURLQueryComponent
-import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import okio.Path.Companion.toPath
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
-import kotlin.reflect.KClass
-
-
-/**
- * Manages the lifecycle of a Zipline plugin.
- * It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
- * Anything it provides, must be called within that dispatcher context.
- * The [use] function must be used.
- *
- * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
- * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
- * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
- *
- * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
- */
-open class ZiplinePluginService(
- val applicationName: String,
- private val manifestUrl: String,
- private val pluginInfo: PluginEntry,
-) : PluginService, KoinComponent {
-
- // QuickJS compile() uses deep C-level recursion on the native thread stack.
- // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
- // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
- // We use a custom EventListener to increase maxStackSize right after the Zipline
- // instance is created, before any modules are loaded.
- private val ziplineDispatcher = createZiplineDispatcher()
-
- private fun trace(event: String) {
- logger.d { "[$applicationName] $event" }
- }
-
- private val logger by injectLogger()
- private val webViewController: WebViewController by inject()
-
- private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
- private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
- logger.e(throwable) { "Zipline Engine Error" }
- }
- private val lifecycleMutex = Mutex()
- private var ziplineLoader: ZiplineLoader
- private var ziplineInstance: Zipline? = null
- private val serviceRegistry = mutableMapOf, ZiplineService>()
-
- init {
- val manifestPath = URLBuilder(manifestUrl)
- val baseDir =
- manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
- ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
- ziplineLoader = ZiplineLoader(
- dispatcher = ziplineDispatcher.dispatcher,
- manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
- httpClient = FileSystemHTTPClient(baseDir)
- )
- }
-
- private val realHttpClientAPI = RealHttpClientAPI()
- private val realWebViewAPI = RealWebViewAPI(scope, webViewController)
-
- private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
- private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
- private val systemInformationAPI = RealSystemInformationAPI()
-
- private val loggedInStateFlow = MutableStateFlow(false)
- override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow()
-
- private fun bindHostServices(zipline: Zipline) {
- trace("initializer(): binding host APIs")
- logger.d { "[$applicationName] Binding host APIs in initializer" }
- try {
- // Basic APIs
- zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI)
- zipline.bind(
- SystemInformationAPI_SERVICE_NAME,
- systemInformationAPI
- )
-
- // Conditional APIs based on plugin capabilities
- if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
- zipline.bind(
- HttpClientAPI_SERVICE_NAME,
- realHttpClientAPI
- )
- }
- if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
- zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI)
- }
- if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
- zipline.bind(
- PersistedStorageAPI_SERVICE_NAME,
- persistedStorageAPI
- )
- }
- } catch (e: Exception) {
- logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
- throw e
- }
- }
-
- private fun consumePluginServices(result: LoadResult.Success) {
- trace("start(): loadOnce success")
- val apiMap =
- buildMap, ZiplineService> {
- put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME))
-
- if (PluginAbility.METADATA in pluginInfo.abilities) {
- put(
- MetadataUserAPI::class,
- result.zipline.take(
- MetadataUserAPI_SERVICE_NAME
- )
- )
- put(
- MetadataTrackAPI::class,
- result.zipline.take(
- MetadataTrackAPI_SERVICE_NAME
- )
- )
- put(
- MetadataAlbumAPI::class,
- result.zipline.take(
- MetadataAlbumAPI_SERVICE_NAME
- )
- )
- put(
- MetadataArtistAPI::class,
- result.zipline.take(
- MetadataArtistAPI_SERVICE_NAME
- )
- )
- put(
- MetadataPlaylistAPI::class,
- result.zipline.take(
- MetadataPlaylistAPI_SERVICE_NAME
- )
- )
- put(
- MetadataBrowseAPI::class,
- result.zipline.take(
- MetadataBrowseAPI_SERVICE_NAME
- )
- )
- put(
- MetadataSearchAPI::class,
- result.zipline.take(
- MetadataSearchAPI_SERVICE_NAME
- )
- )
- }
- if (PluginAbility.AUDIO in pluginInfo.abilities) {
- put(
- AudioAPI::class,
- result.zipline.take(AudioAPI_SERVICE_NAME)
- )
- }
- if (PluginAbility.LYRICS in pluginInfo.abilities) {
- put(
- LyricsAPI::class,
- result.zipline.take(LyricsAPI_SERVICE_NAME)
- )
- }
- if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
- put(
- ScrobbleAPI::class,
- result.zipline.take(ScrobbleAPI_SERVICE_NAME)
- )
- }
- }
-
- serviceRegistry.putAll(apiMap)
- trace("start(): API ready")
- }
-
- private fun runLogInFlowObservers() = scope.launch {
- val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
- coreAPI.loggedInFlow.collect { isLoggedIn ->
- loggedInStateFlow.value = isLoggedIn
- }
- }
-
- override suspend fun start() {
- lifecycleMutex.withLock {
- trace("start(): entered")
- if (serviceRegistry.isNotEmpty()) {
- trace("start(): already started, skipping")
- return
- }
-
- logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
- withContext(ziplineDispatcher.dispatcher) {
- trace("start(): inside zipline dispatcher before loadOnce")
- val result = ziplineLoader.loadOnce(
- applicationName = applicationName,
- manifestUrl = manifestUrl,
- freshnessChecker = DefaultFreshnessCheckerNotFresh,
- )
- when (result) {
- is LoadResult.Success -> {
- logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
- ziplineInstance = result.zipline
- // Now we consume the initializer
- val initializer = result.zipline.take(Initializer_SERVICE_NAME)
- // Bind host services before initialization, so plugins can use them in their initializer
- bindHostServices(result.zipline)
- trace("start(): calling initializer.initialize()")
- runCatching { initializer.initialize() }
- .onSuccess {
- consumePluginServices(result)
- runLogInFlowObservers()
- }
- .onFailure { e ->
- logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
- throw e
- }
- }
-
- is LoadResult.Failure -> {
- trace("start(): loadOnce failure: ${result.exception}")
- logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
- throw result.exception
- }
- }
- }
-
- }
- }
-
- override suspend fun stop() {
- lifecycleMutex.withLock {
- trace("stop(): entered")
- withContext(ziplineDispatcher.dispatcher) {
- ziplineInstance?.close()
- ziplineInstance = null
- for (service in serviceRegistry.values) {
- try {
- trace("stop(): closing service ${service::class.simpleName}")
- service.close()
- } catch (_: Exception) {
- trace("stop(): error closing service ${service::class.simpleName}")
- }
- }
- }
- serviceRegistry.clear()
- scope.cancel()
- loggedInStateFlow.value = false
- ziplineDispatcher.close()
- trace("stop(): completed")
- }
- }
-
- override suspend fun use(block: suspend PluginServiceScope.() -> T): T {
- return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
- // Create the scope with the current registry
- val scope = PluginServiceScope(serviceRegistry)
- // Execute the block with 'scope' as 'this'
- scope.block()
- }
- }
+/*
+ * 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.zipline
+
+import app.cash.zipline.Zipline
+import app.cash.zipline.ZiplineService
+import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
+import app.cash.zipline.loader.LoadResult
+import app.cash.zipline.loader.ManifestVerifier
+import app.cash.zipline.loader.ZiplineLoader
+import dev.krtirtho.plugin_interfaces.core.Initializer
+import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
+import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
+import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
+import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
+import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
+import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
+import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
+import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
+import dev.krtirtho.spotube.core.di.injectLogger
+import dev.krtirtho.spotube.core.webview.WebViewController
+import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
+import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
+import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
+import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
+import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
+import dev.krtirtho.spotube.modules.plugin.PluginAbility
+import dev.krtirtho.spotube.modules.plugin.PluginCapability
+import dev.krtirtho.spotube.modules.plugin.PluginEntry
+import io.ktor.http.URLBuilder
+import io.ktor.http.decodeURLQueryComponent
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import okio.Path.Companion.toPath
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import kotlin.reflect.KClass
+
+
+/**
+ * Manages the lifecycle of a Zipline plugin.
+ * It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
+ * Anything it provides, must be called within that dispatcher context.
+ * The [use] function must be used.
+ *
+ * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
+ * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
+ * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
+ *
+ * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
+ */
+open class ZiplinePluginService(
+ val applicationName: String,
+ private val manifestUrl: String,
+ private val pluginInfo: PluginEntry,
+) : PluginService, KoinComponent {
+
+ // QuickJS compile() uses deep C-level recursion on the native thread stack.
+ // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
+ // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
+ // We use a custom EventListener to increase maxStackSize right after the Zipline
+ // instance is created, before any modules are loaded.
+ private val ziplineDispatcher = createZiplineDispatcher()
+
+ private fun trace(event: String) {
+ logger.d { "[$applicationName] $event" }
+ }
+
+ private val logger by injectLogger()
+ private val webViewController: WebViewController by inject()
+
+ private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
+ private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
+ logger.e(throwable) { "Zipline Engine Error" }
+ }
+ private val lifecycleMutex = Mutex()
+ private var ziplineLoader: ZiplineLoader
+ private var ziplineInstance: Zipline? = null
+ private val serviceRegistry = mutableMapOf, ZiplineService>()
+
+ init {
+ val manifestPath = URLBuilder(manifestUrl)
+ val baseDir =
+ manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
+ ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
+ ziplineLoader = ZiplineLoader(
+ dispatcher = ziplineDispatcher.dispatcher,
+ manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
+ httpClient = FileSystemHTTPClient(baseDir)
+ )
+ }
+
+ private val realHttpClientAPI = RealHttpClientAPI()
+ private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
+
+ private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
+ private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
+ private val systemInformationAPI = RealSystemInformationAPI()
+
+ private val loggedInStateFlow = MutableStateFlow(false)
+ override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow()
+
+ private fun bindHostServices(zipline: Zipline) {
+ trace("initializer(): binding host APIs")
+ logger.d { "[$applicationName] Binding host APIs in initializer" }
+ try {
+ // Basic APIs
+ zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI)
+ zipline.bind(
+ SystemInformationAPI_SERVICE_NAME,
+ systemInformationAPI
+ )
+
+ // Conditional APIs based on plugin capabilities
+ if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
+ zipline.bind(
+ HttpClientAPI_SERVICE_NAME,
+ realHttpClientAPI
+ )
+ }
+ if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
+ zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI)
+ }
+ if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
+ zipline.bind(
+ PersistedStorageAPI_SERVICE_NAME,
+ persistedStorageAPI
+ )
+ }
+ } catch (e: Exception) {
+ logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
+ throw e
+ }
+ }
+
+ private fun consumePluginServices(result: LoadResult.Success) {
+ trace("start(): loadOnce success")
+ val apiMap =
+ buildMap, ZiplineService> {
+ put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME))
+
+ if (PluginAbility.METADATA in pluginInfo.abilities) {
+ put(
+ MetadataUserAPI::class,
+ result.zipline.take(
+ MetadataUserAPI_SERVICE_NAME
+ )
+ )
+ put(
+ MetadataTrackAPI::class,
+ result.zipline.take(
+ MetadataTrackAPI_SERVICE_NAME
+ )
+ )
+ put(
+ MetadataAlbumAPI::class,
+ result.zipline.take(
+ MetadataAlbumAPI_SERVICE_NAME
+ )
+ )
+ put(
+ MetadataArtistAPI::class,
+ result.zipline.take(
+ MetadataArtistAPI_SERVICE_NAME
+ )
+ )
+ put(
+ MetadataPlaylistAPI::class,
+ result.zipline.take(
+ MetadataPlaylistAPI_SERVICE_NAME
+ )
+ )
+ put(
+ MetadataBrowseAPI::class,
+ result.zipline.take(
+ MetadataBrowseAPI_SERVICE_NAME
+ )
+ )
+ put(
+ MetadataSearchAPI::class,
+ result.zipline.take(
+ MetadataSearchAPI_SERVICE_NAME
+ )
+ )
+ }
+ if (PluginAbility.AUDIO in pluginInfo.abilities) {
+ put(
+ AudioAPI::class,
+ result.zipline.take(AudioAPI_SERVICE_NAME)
+ )
+ }
+ if (PluginAbility.LYRICS in pluginInfo.abilities) {
+ put(
+ LyricsAPI::class,
+ result.zipline.take(LyricsAPI_SERVICE_NAME)
+ )
+ }
+ if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
+ put(
+ ScrobbleAPI::class,
+ result.zipline.take(ScrobbleAPI_SERVICE_NAME)
+ )
+ }
+ }
+
+ serviceRegistry.putAll(apiMap)
+ trace("start(): API ready")
+ }
+
+ private fun runLogInFlowObservers() = scope.launch {
+ val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
+ coreAPI.loggedInFlow.collect { isLoggedIn ->
+ loggedInStateFlow.value = isLoggedIn
+ }
+ }
+
+ override suspend fun start() {
+ lifecycleMutex.withLock {
+ trace("start(): entered")
+ if (serviceRegistry.isNotEmpty()) {
+ trace("start(): already started, skipping")
+ return
+ }
+
+ logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
+ withContext(ziplineDispatcher.dispatcher) {
+ trace("start(): inside zipline dispatcher before loadOnce")
+ val result = ziplineLoader.loadOnce(
+ applicationName = applicationName,
+ manifestUrl = manifestUrl,
+ freshnessChecker = DefaultFreshnessCheckerNotFresh,
+ )
+ when (result) {
+ is LoadResult.Success -> {
+ logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
+ ziplineInstance = result.zipline
+ // Now we consume the initializer
+ val initializer = result.zipline.take(Initializer_SERVICE_NAME)
+ // Bind host services before initialization, so plugins can use them in their initializer
+ bindHostServices(result.zipline)
+ trace("start(): calling initializer.initialize()")
+ runCatching { initializer.initialize() }
+ .onSuccess {
+ consumePluginServices(result)
+ runLogInFlowObservers()
+ }
+ .onFailure { e ->
+ logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
+ throw e
+ }
+ }
+
+ is LoadResult.Failure -> {
+ trace("start(): loadOnce failure: ${result.exception}")
+ logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
+ throw result.exception
+ }
+ }
+ }
+
+ }
+ }
+
+ override suspend fun stop() {
+ lifecycleMutex.withLock {
+ trace("stop(): entered")
+ withContext(ziplineDispatcher.dispatcher) {
+ ziplineInstance?.close()
+ ziplineInstance = null
+ for (service in serviceRegistry.values) {
+ try {
+ trace("stop(): closing service ${service::class.simpleName}")
+ service.close()
+ } catch (_: Exception) {
+ trace("stop(): error closing service ${service::class.simpleName}")
+ }
+ }
+ }
+ serviceRegistry.clear()
+ scope.cancel()
+ loggedInStateFlow.value = false
+ ziplineDispatcher.close()
+ trace("stop(): completed")
+ }
+ }
+
+ override suspend fun use(block: suspend PluginServiceScope.() -> T): T {
+ return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
+ // Create the scope with the current registry
+ val scope = PluginServiceScope(serviceRegistry)
+ // Execute the block with 'scope' as 'this'
+ scope.block()
+ }
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt
index 48e28b4b..80f906cb 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt
@@ -1,76 +1,74 @@
-/*
- * 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.zipline.host_apis
-
-import dev.krtirtho.plugin_interfaces.host_apis.Cookie
-import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
-import dev.krtirtho.spotube.core.webview.WebViewController
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-class RealWebViewAPI(
- private val scope: CoroutineScope,
- private val webViewController: WebViewController,
-) : WebViewAPI {
- override fun navigateTo(url: String) {
- scope.launch(Dispatchers.Main) {
- webViewController.navigateTo(url)
- }
- }
-
- override fun navigateToHTML(html: String) {
- scope.launch {
- webViewController.navigateToHTML(html)
- }
- }
-
- override suspend fun getCookies(url: String): List {
- return withContext(Dispatchers.Main) {
- webViewController.getCookies(url)
- }
- }
-
- override suspend fun evaluateJavaScript(script: String): String? {
- return withContext(Dispatchers.Main) {
- webViewController.evaluateJavascript(script)
- }
- }
-
- override fun urlChangeFlow(): Flow {
- return webViewController.urlChangedFlow
- }
- override fun webviewCreatedFlow(): Flow {
- return webViewController.webviewCreatedFlow
- }
- override fun postMessagesFlow(): Flow {
- return webViewController.postMessagesFlow
- }
-
- override fun exitWebView() {
- scope.launch(Dispatchers.Main) {
- webViewController.closeWebview()
- }
- }
-
+/*
+ * 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.zipline.host_apis
+
+import dev.krtirtho.plugin_interfaces.host_apis.Cookie
+import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
+import dev.krtirtho.spotube.core.webview.WebViewController
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class RealWebViewAPI(
+ private val scope: CoroutineScope,
+ private val webViewController: WebViewController,
+ private val pluginId: String,
+) : WebViewAPI {
+ override fun navigateTo(url: String) {
+ scope.launch(Dispatchers.Main) {
+ webViewController.navigateTo(url, pluginId)
+ }
+ }
+
+ override fun navigateToHTML(html: String) {
+ scope.launch {
+ webViewController.navigateToHTML(html, pluginId)
+ }
+ }
+
+ override suspend fun getCookies(url: String): List {
+ return withContext(Dispatchers.Main) {
+ webViewController.getCookies(url)
+ }
+ }
+
+ override suspend fun evaluateJavaScript(script: String): String? {
+ return withContext(Dispatchers.Main) {
+ webViewController.evaluateJavascript(script)
+ }
+ }
+
+ override fun urlChangeFlow(): Flow {
+ return webViewController.urlChangedFlow
+ }
+ override fun webviewCreatedFlow(): Flow {
+ return webViewController.webviewCreatedFlow
+ }
+ override fun postMessagesFlow(): Flow {
+ return webViewController.postMessagesFlow
+ }
+
+ override fun exitWebView() {
+ scope.launch(Dispatchers.Main) {
+ webViewController.closeWebview()
+ }
+ }
+
}
\ No newline at end of file
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 307011c5..f01f5266 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
@@ -1,1274 +1,1274 @@
-/*
- * 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
-
-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.PaddingValues
-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.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.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-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.graphics.Color
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil3.compose.AsyncImage
-import coil3.compose.LocalPlatformContext
-import coil3.request.ImageRequest
-import coil3.request.crossfade
-import dev.krtirtho.spotube.PlatformType
-import dev.krtirtho.spotube.core.extras.kebabToTitleCase
-import dev.krtirtho.spotube.core.ui.base.Card
-import dev.krtirtho.spotube.core.ui.base.OutlineButton
-import dev.krtirtho.spotube.core.ui.base.PrimaryButton
-import dev.krtirtho.spotube.core.ui.base.SecondaryButton
-import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
-import dev.krtirtho.spotube.core.ui.base.TextField
-import dev.krtirtho.spotube.core.ui.base.ThemedDialog
-import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
-import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
-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.PluginCard
-import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog
-import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
-import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
-import dev.krtirtho.spotube.openUrlInBrowser
-import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo
-import dev.krtirtho.spotube.resources.iconsax.Iconsax
-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 io.github.vinceglb.filekit.readBytes
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.launch
-import net.swiftzer.semver.SemVer
-import okio.FileSystem
-import okio.Path.Companion.toPath
-import okio.SYSTEM
-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.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_subtitle
-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
-import spotube.composeapp.generated.resources.plugin_error_url_scheme
-import spotube.composeapp.generated.resources.plugin_install_section_title
-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_screen_title
-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
-import spotube.composeapp.generated.resources.settings_plugins_no_plugins
-import spotube.composeapp.generated.resources.settings_plugins_no_selection
-import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
-
-private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube")
-
-private val VERIFIED_PLUGIN_OWNERS = setOf()
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun PluginScreen(
- pluginManager: PluginManager,
- webviewController: WebViewController = koinInject()
-) {
- val scope = rememberCoroutineScope()
- val platform = remember { getPlatform() }
- val pendingPlugin by pluginManager.pendingPlugin.collectAsStateWithLifecycle()
- val pluginsState by pluginManager.state.collectAsStateWithLifecycle()
- val activeServices by pluginManager.ziplineServices.collectAsStateWithLifecycle()
- val shellBottomInset = LocalAppShellBottomInset.current
-
- var urlInput by remember { mutableStateOf("") }
- var urlError by remember { mutableStateOf(null) }
- var isLoadingUrl by remember { mutableStateOf(false) }
- var showInstallSheet by remember { mutableStateOf(false) }
-
- val discoverViewModel: PluginDiscoverViewModel = koinViewModel()
- val discoverState by discoverViewModel.state.collectAsStateWithLifecycle()
-
- var showPluginInfo by remember { mutableStateOf(null) }
- var showPluginSupport by remember { mutableStateOf(null) }
- var supportText by remember { mutableStateOf(null) }
- var isLoadingSupport by remember { mutableStateOf(false) }
-
- var installDialogRepo by remember { mutableStateOf(null) }
- var releases by remember { mutableStateOf>(emptyList()) }
- var isLoadingReleases by remember { mutableStateOf(false) }
-
- val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
- val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
- val downloadFailed = stringResource(Res.string.plugin_error_download_failed)
-
- val launcher = rememberFilePickerLauncher(
- type = FileKitType.File(
- extensions = if (platform.type == PlatformType.Android) listOf() else listOf("smplug")
- )
- ) { file ->
- if (file != null) {
- scope.launch { pluginManager.preparePlugin(file.readBytes()) }
- }
- }
-
- fun submitUrl() {
- val url = urlInput.trim()
- if (url.isBlank()) {
- urlError = pleaseEnterUrl
- return
- }
- if (!url.startsWith("http://") && !url.startsWith("https://")) {
- urlError = urlSchemeError
- return
- }
- urlError = null
- isLoadingUrl = true
- scope.launch {
- try {
- pluginManager.addPluginFromURL(url)
- urlInput = ""
- } catch (e: Exception) {
- urlError = e.message ?: downloadFailed
- } finally {
- isLoadingUrl = false
- }
- }
- }
-
- pendingPlugin?.let { pending ->
- val logoPath = remember(pending.entry.id) {
- val path = pluginManager.pluginsDirPath / pending.entry.id.toPath() / "logo.png".toPath()
- if (FileSystem.SYSTEM.exists(path)) path else null
- }
- PluginPermissionDialog(
- pluginInfo = pending.entry,
- title = pending.title,
- message = pending.message,
- confirmLabel = pending.confirmLabel,
- existingPlugin = pending.existingEntry,
- logoPath = logoPath,
- onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) {
- { pluginManager.confirmInstall() }
- } else {
- null
- },
- onDismiss = { pluginManager.dismissInstall() }
- )
- }
-
- showPluginInfo?.let { plugin ->
- val logoPath = remember(plugin.id) {
- val path = pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath()
- if (FileSystem.SYSTEM.exists(path)) path else null
- }
- ThemedDialog(
- onDismissRequest = { showPluginInfo = null },
- title = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- Surface(
- modifier = Modifier
- .size(48.dp)
- .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
- )
- }
- }
- }
- Text(
- plugin.name,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold
- )
- }
- },
- actions = {
- PrimaryButton(onClick = { showPluginInfo = null }) {
- Text("Close")
- }
- }
- ) {
- Column(
- verticalArrangement = Arrangement.spacedBy(12.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- if (plugin.description.isNotBlank()) {
- Text(
- plugin.description,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
- }
-
- DetailRow("Version", plugin.version)
- DetailRow("API Version", plugin.apiVersion)
- DetailRow("Author", plugin.author)
- if (plugin.license.isNotBlank()) {
- DetailRow("License", plugin.license)
- }
-
- if (plugin.capabilities.isNotEmpty()) {
- DetailChipsRow("Capabilities", plugin.capabilities.map { it.name })
- }
-
- if (plugin.abilities.isNotEmpty()) {
- DetailChipsRow("Abilities", plugin.abilities.map { ability ->
- when (ability) {
- PluginAbility.METADATA -> "Metadata"
- PluginAbility.AUDIO -> "Audio"
- PluginAbility.LYRICS -> "Lyrics"
- PluginAbility.SCROBBLE -> "Scrobble"
- }
- })
- }
-
- if (plugin.repository.isNotBlank()) {
- ClickableDetailRow(label = "Repository", value = plugin.repository) {
- openUrlInBrowser(plugin.repository)
- }
- }
-
- if (plugin.contact.isNotBlank()) {
- DetailRow("Contact", plugin.contact)
- }
-
- if (plugin.bugs.isNotBlank()) {
- ClickableDetailRow(label = "Report Bugs", value = plugin.bugs) {
- openUrlInBrowser(plugin.bugs)
- }
- }
- }
- }
- }
-
- showPluginSupport?.let { plugin ->
- ThemedDialog(
- onDismissRequest = { showPluginSupport = null; supportText = null },
- title = {
- Text(
- "Support ${plugin.name}",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold
- )
- },
- actions = {
- PrimaryButton(onClick = { showPluginSupport = null; supportText = null }) {
- Text("Close")
- }
- }
- ) {
- if (isLoadingSupport) {
- Box(
- modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
- contentAlignment = Alignment.Center
- ) {
- CircularProgressIndicator()
- }
- } else {
- Text(
- supportText ?: "",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
- }
- }
- }
-
- installDialogRepo?.let { repo ->
- PluginInstallDialog(
- repo = repo,
- releases = releases,
- isLoadingReleases = isLoadingReleases,
- onDismiss = { installDialogRepo = null },
- onInstall = { release ->
- installDialogRepo = null
- val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
- if (smplugUrl != null) {
- discoverViewModel.installPluginFromUrl(smplugUrl, repo.id)
- }
- }
- )
- }
-
- 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)) {
- Text(
- stringResource(Res.string.plugin_section_url_title),
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- 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))
-
- Text(
- stringResource(Res.string.plugin_section_file_title),
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- 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)) })
- }
- ) { innerPadding ->
- when (val state = pluginsState) {
- is PluginManagerStates.Loading -> {
- Box(
- modifier = Modifier.fillMaxSize().padding(innerPadding),
- contentAlignment = Alignment.Center
- ) { CircularProgressIndicator() }
- }
-
- is PluginManagerStates.Data -> {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- ) {
- val discoverListState = rememberLazyListState()
- LazyColumn(
- state = discoverListState,
- modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
- contentPadding = PaddingValues(
- start = 12.dp,
- end = 12.dp,
- top = 8.dp,
- bottom = 24.dp + shellBottomInset
- ),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- )
- {
- // ── Configure header ──────────────────────────────
- item {
- 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.titleLarge,
- 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 ─────────────────
- item {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp),
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 4.dp, bottom = 4.dp)
- ) {
- PluginAbility.entries.forEachIndexed { index, ability ->
- if (index > 0) {
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant.copy(
- alpha = 0.5f
- ),
- )
- }
- val selectedPlugin = state.selectedPlugins[ability]
- DefaultAbilityPluginSelector(
- ability = ability,
- selectedPlugin = selectedPlugin,
- state = when (ability) {
- PluginAbility.METADATA -> pluginManager.metadataPlugins
- PluginAbility.AUDIO -> pluginManager.audioPlugins
- PluginAbility.LYRICS -> pluginManager.lyricsPlugins
- PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins
- },
- onSelected = { plugin ->
- pluginManager.setSelectedPlugin(ability, plugin)
- },
- )
- }
- }
- }
- }
-
- if (state.plugins.isEmpty()) {
- item {
- Box(
- modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
- contentAlignment = Alignment.Center
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Surface(
- modifier = Modifier.size(72.dp)
- .clip(RoundedCornerShape(18.dp)),
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- Iconsax.IconsaxBox,
- contentDescription = null,
- modifier = Modifier.size(32.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- }
- }
- Text(
- stringResource(Res.string.plugin_empty_title),
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.SemiBold
- )
- Text(
- stringResource(Res.string.plugin_empty_subtitle),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
- } else {
- // ── Plugin list ───────────────────────────────────
- item {
- val noun = if (state.plugins.size == 1) {
- stringResource(Res.string.plugin_installed_singular)
- } else {
- stringResource(Res.string.plugin_installed_plural)
- }
- Text(
- stringResource(
- Res.string.plugin_installed_count,
- state.plugins.size,
- noun
- ),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
- )
- }
- item {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp),
- ) {
- Column(
- modifier = Modifier.fillMaxWidth()
- ) {
- state.plugins.forEachIndexed { index, plugin ->
- if (index > 0) {
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant.copy(
- alpha = 0.5f
- ),
- )
- }
- val isSelected =
- state.selectedPlugins.containsValue(plugin)
- val selectedAbility = state.selectedPlugins
- .entries
- .firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id }
- ?.key
- val selectedService = selectedAbility?.let { ability ->
- activeServices?.get(ability)
- }
-
- var requiresAuth by remember(
- plugin.id,
- selectedService
- ) {
- mutableStateOf(false)
- }
- var isLoggedIn by remember(plugin.id, selectedService) {
- mutableStateOf(false)
- }
-
- LaunchedEffect(plugin.id, selectedService) {
- requiresAuth = false
- isLoggedIn = false
- val service =
- selectedService ?: return@LaunchedEffect
-
-
- service.use {
- val pluginRequiresAuth =
- coreAPI.requiresAuthentication
- requiresAuth = pluginRequiresAuth
- if (!pluginRequiresAuth) return@use
-
- coreAPI.loggedInFlow.collect { loggedIn ->
- isLoggedIn = loggedIn
- }
- }
- }
-
- val logoPath = remember(plugin.id) {
- val path =
- pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath()
- if (FileSystem.SYSTEM.exists(path)) path else null
- }
-
- PluginCard(
- plugin = plugin,
- isSelected = isSelected,
- onRemove = {
- scope.launch { pluginManager.removePlugin(plugin) }
- },
- isLoggedIn = isLoggedIn,
- logoPath = logoPath,
- onInfo = { showPluginInfo = plugin },
- onSupport = if (selectedService != null) {
- {
- isLoadingSupport = true
- scope.launch {
- showPluginSupport = plugin
- val version = SemVer.parse(plugin.version)
- selectedService.use {
- supportText =
- coreAPI.supportMarkdownText(version)
- }
- isLoadingSupport = false
- }
- }
- } else {
- null
- },
- onLogin = if (requiresAuth && selectedService != null) {
- {
- pluginManager.launchTask {
- selectedService.use { coreAPI.login() }
- }
- }
- } else {
- null
- },
- onLogout = if (requiresAuth && selectedService != null) {
- {
- pluginManager.launchTask {
- selectedService.use { coreAPI.logout() }
- }
- // should clear webview data after logout
- scope.launch { webviewController.clearData() }
- }
- } else {
- null
- }
- )
- }
- }
- }
- }
- }
-
- // ── Discover plugins ─────────────────────────
- if (discoverState.isLoading || discoverState.repos.isNotEmpty()) {
- item {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Icon(
- Iconsax.IconsaxGlobe,
- contentDescription = null,
- modifier = Modifier.size(18.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- Text(
- "Discover Plugins",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.SemiBold
- )
- }
- }
- items(
- discoverState.repos,
- key = { it.id }
- ) { repo ->
- val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS
- val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS
- val isInstalling = discoverState.installingRepoId == repo.id
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp)
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- val platformContext = LocalPlatformContext.current
- AsyncImage(
- model = ImageRequest.Builder(platformContext)
- .data(repo.owner.avatarUrl)
- .crossfade(true)
- .build(),
- contentDescription = repo.owner.login,
- modifier = Modifier
- .size(36.dp)
- .clip(RoundedCornerShape(8.dp))
- )
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(2.dp)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp)
- ) {
- Text(
- repo.fullName.split("/")
- .last()
- .replace("spotube-plugin-", "")
- .kebabToTitleCase(),
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f, fill = false)
- )
- if (isOfficial) {
- Surface(
- shape = RoundedCornerShape(4.dp),
- color = MaterialTheme.colorScheme.primary.copy(
- alpha = 0.15f
- )
- ) {
- Text(
- "Official",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(
- horizontal = 5.dp,
- vertical = 1.dp
- )
- )
- }
- } else if (isVerified) {
- Surface(
- shape = RoundedCornerShape(4.dp),
- color = Color(0xFF4CAF50).copy(alpha = 0.15f)
- ) {
- Row(
- modifier = Modifier.padding(
- horizontal = 5.dp,
- vertical = 1.dp
- ),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(
- 2.dp
- )
- ) {
- Icon(
- Iconsax.IconsaxCheckCircle,
- contentDescription = null,
- modifier = Modifier.size(10.dp),
- tint = Color(0xFF4CAF50)
- )
- Text(
- "Verified",
- style = MaterialTheme.typography.labelSmall,
- color = Color(0xFF4CAF50)
- )
- }
- }
- }
- }
- if (!repo.description.isNullOrBlank()) {
- Text(
- repo.description,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- repo.owner.login,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(3.dp)
- ) {
- Icon(
- Iconsax.IconsaxHeart,
- contentDescription = "Github Stars",
- modifier = Modifier.size(11.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Text(
- repo.stargazersCount.toString(),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- Surface(
- shape = RoundedCornerShape(4.dp),
- color = MaterialTheme.colorScheme.surfaceVariant,
- modifier = Modifier.clickable {
- openUrlInBrowser(repo.htmlUrl)
- }
- ) {
- Row(
- modifier = Modifier.padding(
- horizontal = 5.dp,
- vertical = 2.dp
- ),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(
- 3.dp
- )
- ) {
- Icon(
- Iconsax.CarbonGithubLogo,
- contentDescription = "Github Repository URL",
- modifier = Modifier.size(10.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Text(
- "github.com",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
- }
- SecondaryButton(
- onClick = {
- installDialogRepo = repo
- isLoadingReleases = true
- releases = emptyList()
- scope.launch {
- val parts = repo.fullName.split("/")
- releases = discoverViewModel.getReleases(parts[0], parts[1])
- isLoadingReleases = false
- }
- },
- enabled = !isInstalling
- ) {
- if (isInstalling) {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp
- )
- } else {
- Icon(
- Iconsax.IconsaxAdd,
- contentDescription = null,
- )
- }
- Text(stringResource(Res.string.plugin_section_install))
- }
- }
- }
- }
-
- if (discoverState.isLoadingMore) {
- item {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp),
- contentAlignment = Alignment.Center
- ) {
- CircularProgressIndicator(modifier = Modifier.size(24.dp))
- }
- }
- }
-
- if (discoverState.error != null) {
- item {
- Text(
- discoverState.error ?: "",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- modifier = Modifier.padding(
- horizontal = 4.dp,
- vertical = 8.dp
- )
- )
- }
- }
- }
- }
-
- val density = LocalDensity.current
- val shouldLoadMore = remember(density) {
- derivedStateOf {
- val totalItems = discoverListState.layoutInfo.totalItemsCount
- val lastVisibleIndex =
- discoverListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
- ?: 0
- totalItems > 0 && lastVisibleIndex >= totalItems - 3
- }
- }
-
- LaunchedEffect(shouldLoadMore.value) {
- if (shouldLoadMore.value) {
- discoverViewModel.loadNextPage()
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun DetailRow(label: String, value: String) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- "$label:",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.widthIn(min = 90.dp)
- )
- Text(
- value,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
- }
-}
-
-@Composable
-private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit) {
- Row(
- modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- "$label:",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.widthIn(min = 90.dp)
- )
- Text(
- value,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.primary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
-}
-
-@Composable
-private fun DetailChipsRow(label: String, chips: List) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- "$label:",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.widthIn(min = 90.dp)
- )
- Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
- chips.forEach { chip ->
- Surface(
- shape = RoundedCornerShape(4.dp),
- color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f)
- ) {
- Text(
- chip,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
- )
- }
- }
- }
- }
-}
-
-@Composable
-fun DefaultAbilityPluginSelector(
- ability: PluginAbility,
- state: StateFlow>,
- selectedPlugin: PluginEntry? = null,
- onSelected: (PluginEntry?) -> Unit = { },
-) {
- val plugins by state.collectAsStateWithLifecycle()
- val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins)
-
- val menuItems = buildList {
- if (plugins.isNotEmpty()) {
- plugins.forEach { plugin ->
- val isSelected = selectedPlugin?.name == plugin.name
- add(
- AdaptiveMenuItem(
- label = plugin.name,
- onClick = { onSelected(plugin) },
- selected = isSelected,
- )
- )
- }
- } else {
- add(
- AdaptiveMenuItem(
- label = noPluginsText,
- onClick = { },
- enabled = false,
- )
- )
- }
- }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(12.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Row(
- modifier = Modifier.weight(1f),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Surface(
- modifier = Modifier.clip(RoundedCornerShape(8.dp)),
- color = when (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)
- }
- ) {
- Icon(
- imageVector = when (ability) {
- PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
- PluginAbility.AUDIO -> Iconsax.IconsaxMusic
- PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
- PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
- },
- contentDescription = stringResource(
- Res.string.settings_plugins_plugin_content_description,
- ability.displayLabel()
- ),
- modifier = Modifier.padding(8.dp),
- tint = when (ability) {
- PluginAbility.METADATA -> Color(0xFF4CAF50)
- PluginAbility.AUDIO -> Color(0xFF2196F3)
- PluginAbility.LYRICS -> Color(0xFFFFC107)
- PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
- }
- )
- }
-
- Column(modifier = Modifier.weight(1f)) {
- Text(
- stringResource(
- Res.string.settings_plugins_default_ability_title,
- ability.displayLabel()
- ),
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurface
- )
- if (selectedPlugin != null) {
- Text(
- selectedPlugin.name,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(top = 4.dp)
- )
- } else {
- Text(
- stringResource(Res.string.settings_plugins_no_selection),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp)
- )
- }
- }
- }
-
- AdaptiveDropdownBottomSheet(
- items = menuItems,
- headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
- header = {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Surface(
- modifier = Modifier.clip(RoundedCornerShape(8.dp)),
- color = when (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)
- }
- ) {
- Icon(
- imageVector = when (ability) {
- PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
- PluginAbility.AUDIO -> Iconsax.IconsaxMusic
- PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
- PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
- },
- contentDescription = null,
- modifier = Modifier.padding(8.dp),
- tint = when (ability) {
- PluginAbility.METADATA -> Color(0xFF4CAF50)
- PluginAbility.AUDIO -> Color(0xFF2196F3)
- PluginAbility.LYRICS -> Color(0xFFFFC107)
- PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
- }
- )
- }
- Column(modifier = Modifier.weight(1f)) {
- Text(
- stringResource(
- Res.string.settings_plugins_default_ability_title,
- ability.displayLabel()
- ),
- style = MaterialTheme.typography.titleMedium,
- )
- selectedPlugin?.let {
- Text(
- it.name,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- }
- }
- },
- trigger = { onClick ->
- OutlineButton(onClick = onClick) {
- Text(
- if (selectedPlugin != null) {
- stringResource(Res.string.settings_plugins_action_change)
- } else {
- stringResource(Res.string.settings_plugins_action_select) + " "
- },
- )
- Icon(
- imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4,
- contentDescription = null,
- modifier = Modifier.size(14.dp),
- )
- }
- },
- )
- }
-}
-
-@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)
- }
-}
-
+/*
+ * 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
+
+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.PaddingValues
+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.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.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil3.compose.AsyncImage
+import coil3.compose.LocalPlatformContext
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import dev.krtirtho.spotube.PlatformType
+import dev.krtirtho.spotube.core.extras.kebabToTitleCase
+import dev.krtirtho.spotube.core.ui.base.Card
+import dev.krtirtho.spotube.core.ui.base.OutlineButton
+import dev.krtirtho.spotube.core.ui.base.PrimaryButton
+import dev.krtirtho.spotube.core.ui.base.SecondaryButton
+import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
+import dev.krtirtho.spotube.core.ui.base.TextField
+import dev.krtirtho.spotube.core.ui.base.ThemedDialog
+import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
+import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
+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.PluginCard
+import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog
+import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
+import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
+import dev.krtirtho.spotube.openUrlInBrowser
+import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+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 io.github.vinceglb.filekit.readBytes
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import net.swiftzer.semver.SemVer
+import okio.FileSystem
+import okio.Path.Companion.toPath
+import okio.SYSTEM
+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.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_subtitle
+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
+import spotube.composeapp.generated.resources.plugin_error_url_scheme
+import spotube.composeapp.generated.resources.plugin_install_section_title
+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_screen_title
+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
+import spotube.composeapp.generated.resources.settings_plugins_no_plugins
+import spotube.composeapp.generated.resources.settings_plugins_no_selection
+import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
+
+private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube")
+
+private val VERIFIED_PLUGIN_OWNERS = setOf()
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PluginScreen(
+ pluginManager: PluginManager,
+ webviewController: WebViewController = koinInject()
+) {
+ val scope = rememberCoroutineScope()
+ val platform = remember { getPlatform() }
+ val pendingPlugin by pluginManager.pendingPlugin.collectAsStateWithLifecycle()
+ val pluginsState by pluginManager.state.collectAsStateWithLifecycle()
+ val activeServices by pluginManager.ziplineServices.collectAsStateWithLifecycle()
+ val shellBottomInset = LocalAppShellBottomInset.current
+
+ var urlInput by remember { mutableStateOf("") }
+ var urlError by remember { mutableStateOf(null) }
+ var isLoadingUrl by remember { mutableStateOf(false) }
+ var showInstallSheet by remember { mutableStateOf(false) }
+
+ val discoverViewModel: PluginDiscoverViewModel = koinViewModel()
+ val discoverState by discoverViewModel.state.collectAsStateWithLifecycle()
+
+ var showPluginInfo by remember { mutableStateOf(null) }
+ var showPluginSupport by remember { mutableStateOf(null) }
+ var supportText by remember { mutableStateOf(null) }
+ var isLoadingSupport by remember { mutableStateOf(false) }
+
+ var installDialogRepo by remember { mutableStateOf(null) }
+ var releases by remember { mutableStateOf>(emptyList()) }
+ var isLoadingReleases by remember { mutableStateOf(false) }
+
+ val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
+ val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
+ val downloadFailed = stringResource(Res.string.plugin_error_download_failed)
+
+ val launcher = rememberFilePickerLauncher(
+ type = FileKitType.File(
+ extensions = if (platform.type == PlatformType.Android) listOf() else listOf("smplug")
+ )
+ ) { file ->
+ if (file != null) {
+ scope.launch { pluginManager.preparePlugin(file.readBytes()) }
+ }
+ }
+
+ fun submitUrl() {
+ val url = urlInput.trim()
+ if (url.isBlank()) {
+ urlError = pleaseEnterUrl
+ return
+ }
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ urlError = urlSchemeError
+ return
+ }
+ urlError = null
+ isLoadingUrl = true
+ scope.launch {
+ try {
+ pluginManager.addPluginFromURL(url)
+ urlInput = ""
+ } catch (e: Exception) {
+ urlError = e.message ?: downloadFailed
+ } finally {
+ isLoadingUrl = false
+ }
+ }
+ }
+
+ pendingPlugin?.let { pending ->
+ val logoPath = remember(pending.entry.id) {
+ val path = pluginManager.pluginsDirPath / pending.entry.id.toPath() / "logo.png".toPath()
+ if (FileSystem.SYSTEM.exists(path)) path else null
+ }
+ PluginPermissionDialog(
+ pluginInfo = pending.entry,
+ title = pending.title,
+ message = pending.message,
+ confirmLabel = pending.confirmLabel,
+ existingPlugin = pending.existingEntry,
+ logoPath = logoPath,
+ onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) {
+ { pluginManager.confirmInstall() }
+ } else {
+ null
+ },
+ onDismiss = { pluginManager.dismissInstall() }
+ )
+ }
+
+ showPluginInfo?.let { plugin ->
+ val logoPath = remember(plugin.id) {
+ val path = pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath()
+ if (FileSystem.SYSTEM.exists(path)) path else null
+ }
+ ThemedDialog(
+ onDismissRequest = { showPluginInfo = null },
+ title = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Surface(
+ modifier = Modifier
+ .size(48.dp)
+ .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
+ )
+ }
+ }
+ }
+ Text(
+ plugin.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ },
+ actions = {
+ PrimaryButton(onClick = { showPluginInfo = null }) {
+ Text("Close")
+ }
+ }
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (plugin.description.isNotBlank()) {
+ Text(
+ plugin.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ DetailRow("Version", plugin.version)
+ DetailRow("API Version", plugin.apiVersion)
+ DetailRow("Author", plugin.author)
+ if (plugin.license.isNotBlank()) {
+ DetailRow("License", plugin.license)
+ }
+
+ if (plugin.capabilities.isNotEmpty()) {
+ DetailChipsRow("Capabilities", plugin.capabilities.map { it.name })
+ }
+
+ if (plugin.abilities.isNotEmpty()) {
+ DetailChipsRow("Abilities", plugin.abilities.map { ability ->
+ when (ability) {
+ PluginAbility.METADATA -> "Metadata"
+ PluginAbility.AUDIO -> "Audio"
+ PluginAbility.LYRICS -> "Lyrics"
+ PluginAbility.SCROBBLE -> "Scrobble"
+ }
+ })
+ }
+
+ if (plugin.repository.isNotBlank()) {
+ ClickableDetailRow(label = "Repository", value = plugin.repository) {
+ openUrlInBrowser(plugin.repository)
+ }
+ }
+
+ if (plugin.contact.isNotBlank()) {
+ DetailRow("Contact", plugin.contact)
+ }
+
+ if (plugin.bugs.isNotBlank()) {
+ ClickableDetailRow(label = "Report Bugs", value = plugin.bugs) {
+ openUrlInBrowser(plugin.bugs)
+ }
+ }
+ }
+ }
+ }
+
+ showPluginSupport?.let { plugin ->
+ ThemedDialog(
+ onDismissRequest = { showPluginSupport = null; supportText = null },
+ title = {
+ Text(
+ "Support ${plugin.name}",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ },
+ actions = {
+ PrimaryButton(onClick = { showPluginSupport = null; supportText = null }) {
+ Text("Close")
+ }
+ }
+ ) {
+ if (isLoadingSupport) {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ Text(
+ supportText ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+
+ installDialogRepo?.let { repo ->
+ PluginInstallDialog(
+ repo = repo,
+ releases = releases,
+ isLoadingReleases = isLoadingReleases,
+ onDismiss = { installDialogRepo = null },
+ onInstall = { release ->
+ installDialogRepo = null
+ val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
+ if (smplugUrl != null) {
+ discoverViewModel.installPluginFromUrl(smplugUrl, repo.id)
+ }
+ }
+ )
+ }
+
+ 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)) {
+ Text(
+ stringResource(Res.string.plugin_section_url_title),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ 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))
+
+ Text(
+ stringResource(Res.string.plugin_section_file_title),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ 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)) })
+ }
+ ) { innerPadding ->
+ when (val state = pluginsState) {
+ is PluginManagerStates.Loading -> {
+ Box(
+ modifier = Modifier.fillMaxSize().padding(innerPadding),
+ contentAlignment = Alignment.Center
+ ) { CircularProgressIndicator() }
+ }
+
+ is PluginManagerStates.Data -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ val discoverListState = rememberLazyListState()
+ LazyColumn(
+ state = discoverListState,
+ modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
+ contentPadding = PaddingValues(
+ start = 12.dp,
+ end = 12.dp,
+ top = 8.dp,
+ bottom = 24.dp + shellBottomInset
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ )
+ {
+ // ── Configure header ──────────────────────────────
+ item {
+ 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.titleLarge,
+ 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 ─────────────────
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp, bottom = 4.dp)
+ ) {
+ PluginAbility.entries.forEachIndexed { index, ability ->
+ if (index > 0) {
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant.copy(
+ alpha = 0.5f
+ ),
+ )
+ }
+ val selectedPlugin = state.selectedPlugins[ability]
+ DefaultAbilityPluginSelector(
+ ability = ability,
+ selectedPlugin = selectedPlugin,
+ state = when (ability) {
+ PluginAbility.METADATA -> pluginManager.metadataPlugins
+ PluginAbility.AUDIO -> pluginManager.audioPlugins
+ PluginAbility.LYRICS -> pluginManager.lyricsPlugins
+ PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins
+ },
+ onSelected = { plugin ->
+ pluginManager.setSelectedPlugin(ability, plugin)
+ },
+ )
+ }
+ }
+ }
+ }
+
+ if (state.plugins.isEmpty()) {
+ item {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Surface(
+ modifier = Modifier.size(72.dp)
+ .clip(RoundedCornerShape(18.dp)),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Iconsax.IconsaxBox,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ Text(
+ stringResource(Res.string.plugin_empty_title),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ stringResource(Res.string.plugin_empty_subtitle),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ } else {
+ // ── Plugin list ───────────────────────────────────
+ item {
+ val noun = if (state.plugins.size == 1) {
+ stringResource(Res.string.plugin_installed_singular)
+ } else {
+ stringResource(Res.string.plugin_installed_plural)
+ }
+ Text(
+ stringResource(
+ Res.string.plugin_installed_count,
+ state.plugins.size,
+ noun
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
+ )
+ }
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ state.plugins.forEachIndexed { index, plugin ->
+ if (index > 0) {
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant.copy(
+ alpha = 0.5f
+ ),
+ )
+ }
+ val isSelected =
+ state.selectedPlugins.containsValue(plugin)
+ val selectedAbility = state.selectedPlugins
+ .entries
+ .firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id }
+ ?.key
+ val selectedService = selectedAbility?.let { ability ->
+ activeServices?.get(ability)
+ }
+
+ var requiresAuth by remember(
+ plugin.id,
+ selectedService
+ ) {
+ mutableStateOf(false)
+ }
+ var isLoggedIn by remember(plugin.id, selectedService) {
+ mutableStateOf(false)
+ }
+
+ LaunchedEffect(plugin.id, selectedService) {
+ requiresAuth = false
+ isLoggedIn = false
+ val service =
+ selectedService ?: return@LaunchedEffect
+
+
+ service.use {
+ val pluginRequiresAuth =
+ coreAPI.requiresAuthentication
+ requiresAuth = pluginRequiresAuth
+ if (!pluginRequiresAuth) return@use
+
+ coreAPI.loggedInFlow.collect { loggedIn ->
+ isLoggedIn = loggedIn
+ }
+ }
+ }
+
+ val logoPath = remember(plugin.id) {
+ val path =
+ pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath()
+ if (FileSystem.SYSTEM.exists(path)) path else null
+ }
+
+ PluginCard(
+ plugin = plugin,
+ isSelected = isSelected,
+ onRemove = {
+ scope.launch { pluginManager.removePlugin(plugin) }
+ },
+ isLoggedIn = isLoggedIn,
+ logoPath = logoPath,
+ onInfo = { showPluginInfo = plugin },
+ onSupport = if (selectedService != null) {
+ {
+ isLoadingSupport = true
+ scope.launch {
+ showPluginSupport = plugin
+ val version = SemVer.parse(plugin.version)
+ selectedService.use {
+ supportText =
+ coreAPI.supportMarkdownText(version)
+ }
+ isLoadingSupport = false
+ }
+ }
+ } else {
+ null
+ },
+ onLogin = if (requiresAuth && selectedService != null) {
+ {
+ pluginManager.launchTask {
+ selectedService.use { coreAPI.login() }
+ }
+ }
+ } else {
+ null
+ },
+ onLogout = if (requiresAuth && selectedService != null) {
+ {
+ pluginManager.launchTask {
+ selectedService.use { coreAPI.logout() }
+ }
+ // should clear webview data after logout
+ scope.launch { webviewController.clearData(plugin.id) }
+ }
+ } else {
+ null
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // ── Discover plugins ─────────────────────────
+ if (discoverState.isLoading || discoverState.repos.isNotEmpty()) {
+ item {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Iconsax.IconsaxGlobe,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ "Discover Plugins",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+ items(
+ discoverState.repos,
+ key = { it.id }
+ ) { repo ->
+ val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS
+ val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS
+ val isInstalling = discoverState.installingRepoId == repo.id
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ val platformContext = LocalPlatformContext.current
+ AsyncImage(
+ model = ImageRequest.Builder(platformContext)
+ .data(repo.owner.avatarUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = repo.owner.login,
+ modifier = Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(8.dp))
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Text(
+ repo.fullName.split("/")
+ .last()
+ .replace("spotube-plugin-", "")
+ .kebabToTitleCase(),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false)
+ )
+ if (isOfficial) {
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = MaterialTheme.colorScheme.primary.copy(
+ alpha = 0.15f
+ )
+ ) {
+ Text(
+ "Official",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(
+ horizontal = 5.dp,
+ vertical = 1.dp
+ )
+ )
+ }
+ } else if (isVerified) {
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = Color(0xFF4CAF50).copy(alpha = 0.15f)
+ ) {
+ Row(
+ modifier = Modifier.padding(
+ horizontal = 5.dp,
+ vertical = 1.dp
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(
+ 2.dp
+ )
+ ) {
+ Icon(
+ Iconsax.IconsaxCheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(10.dp),
+ tint = Color(0xFF4CAF50)
+ )
+ Text(
+ "Verified",
+ style = MaterialTheme.typography.labelSmall,
+ color = Color(0xFF4CAF50)
+ )
+ }
+ }
+ }
+ }
+ if (!repo.description.isNullOrBlank()) {
+ Text(
+ repo.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ repo.owner.login,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(3.dp)
+ ) {
+ Icon(
+ Iconsax.IconsaxHeart,
+ contentDescription = "Github Stars",
+ modifier = Modifier.size(11.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ repo.stargazersCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ modifier = Modifier.clickable {
+ openUrlInBrowser(repo.htmlUrl)
+ }
+ ) {
+ Row(
+ modifier = Modifier.padding(
+ horizontal = 5.dp,
+ vertical = 2.dp
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(
+ 3.dp
+ )
+ ) {
+ Icon(
+ Iconsax.CarbonGithubLogo,
+ contentDescription = "Github Repository URL",
+ modifier = Modifier.size(10.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ "github.com",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ SecondaryButton(
+ onClick = {
+ installDialogRepo = repo
+ isLoadingReleases = true
+ releases = emptyList()
+ scope.launch {
+ val parts = repo.fullName.split("/")
+ releases = discoverViewModel.getReleases(parts[0], parts[1])
+ isLoadingReleases = false
+ }
+ },
+ enabled = !isInstalling
+ ) {
+ if (isInstalling) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Icon(
+ Iconsax.IconsaxAdd,
+ contentDescription = null,
+ )
+ }
+ Text(stringResource(Res.string.plugin_section_install))
+ }
+ }
+ }
+ }
+
+ if (discoverState.isLoadingMore) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+ }
+ }
+
+ if (discoverState.error != null) {
+ item {
+ Text(
+ discoverState.error ?: "",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(
+ horizontal = 4.dp,
+ vertical = 8.dp
+ )
+ )
+ }
+ }
+ }
+ }
+
+ val density = LocalDensity.current
+ val shouldLoadMore = remember(density) {
+ derivedStateOf {
+ val totalItems = discoverListState.layoutInfo.totalItemsCount
+ val lastVisibleIndex =
+ discoverListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
+ ?: 0
+ totalItems > 0 && lastVisibleIndex >= totalItems - 3
+ }
+ }
+
+ LaunchedEffect(shouldLoadMore.value) {
+ if (shouldLoadMore.value) {
+ discoverViewModel.loadNextPage()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DetailRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "$label:",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.widthIn(min = 90.dp)
+ )
+ Text(
+ value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+@Composable
+private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "$label:",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.widthIn(min = 90.dp)
+ )
+ Text(
+ value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+}
+
+@Composable
+private fun DetailChipsRow(label: String, chips: List) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "$label:",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.widthIn(min = 90.dp)
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ chips.forEach { chip ->
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f)
+ ) {
+ Text(
+ chip,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun DefaultAbilityPluginSelector(
+ ability: PluginAbility,
+ state: StateFlow>,
+ selectedPlugin: PluginEntry? = null,
+ onSelected: (PluginEntry?) -> Unit = { },
+) {
+ val plugins by state.collectAsStateWithLifecycle()
+ val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins)
+
+ val menuItems = buildList {
+ if (plugins.isNotEmpty()) {
+ plugins.forEach { plugin ->
+ val isSelected = selectedPlugin?.name == plugin.name
+ add(
+ AdaptiveMenuItem(
+ label = plugin.name,
+ onClick = { onSelected(plugin) },
+ selected = isSelected,
+ )
+ )
+ }
+ } else {
+ add(
+ AdaptiveMenuItem(
+ label = noPluginsText,
+ onClick = { },
+ enabled = false,
+ )
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Surface(
+ modifier = Modifier.clip(RoundedCornerShape(8.dp)),
+ color = when (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)
+ }
+ ) {
+ Icon(
+ imageVector = when (ability) {
+ PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
+ PluginAbility.AUDIO -> Iconsax.IconsaxMusic
+ PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
+ PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
+ },
+ contentDescription = stringResource(
+ Res.string.settings_plugins_plugin_content_description,
+ ability.displayLabel()
+ ),
+ modifier = Modifier.padding(8.dp),
+ tint = when (ability) {
+ PluginAbility.METADATA -> Color(0xFF4CAF50)
+ PluginAbility.AUDIO -> Color(0xFF2196F3)
+ PluginAbility.LYRICS -> Color(0xFFFFC107)
+ PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
+ }
+ )
+ }
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ stringResource(
+ Res.string.settings_plugins_default_ability_title,
+ ability.displayLabel()
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (selectedPlugin != null) {
+ Text(
+ selectedPlugin.name,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ } else {
+ Text(
+ stringResource(Res.string.settings_plugins_no_selection),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ }
+ }
+
+ AdaptiveDropdownBottomSheet(
+ items = menuItems,
+ headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
+ header = {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Surface(
+ modifier = Modifier.clip(RoundedCornerShape(8.dp)),
+ color = when (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)
+ }
+ ) {
+ Icon(
+ imageVector = when (ability) {
+ PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
+ PluginAbility.AUDIO -> Iconsax.IconsaxMusic
+ PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
+ PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
+ },
+ contentDescription = null,
+ modifier = Modifier.padding(8.dp),
+ tint = when (ability) {
+ PluginAbility.METADATA -> Color(0xFF4CAF50)
+ PluginAbility.AUDIO -> Color(0xFF2196F3)
+ PluginAbility.LYRICS -> Color(0xFFFFC107)
+ PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
+ }
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ stringResource(
+ Res.string.settings_plugins_default_ability_title,
+ ability.displayLabel()
+ ),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ selectedPlugin?.let {
+ Text(
+ it.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ },
+ trigger = { onClick ->
+ OutlineButton(onClick = onClick) {
+ Text(
+ if (selectedPlugin != null) {
+ stringResource(Res.string.settings_plugins_action_change)
+ } else {
+ stringResource(Res.string.settings_plugins_action_select) + " "
+ },
+ )
+ Icon(
+ imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ )
+ }
+ },
+ )
+ }
+}
+
+@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/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt
index 13ee072b..fbb391db 100644
--- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt
+++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt
@@ -1,23 +1,41 @@
-/*
- * 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.webview
-
-import io.github.kdroidfilter.webview.web.WebViewState
-
-actual fun platformWebviewConfig(webView: WebViewState) {
+/*
+ * 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.webview
+
+import io.github.kdroidfilter.webview.web.WebViewState
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Foundation.NSDate
+import platform.Foundation.NSHTTPCookieStorage
+import platform.Foundation.distantPast
+import platform.WebKit.WKWebsiteDataStore
+import platform.WebKit.WKWebsiteDataTypeCookies
+import platform.WebKit.WKWebsiteDataTypeLocalStorage
+
+actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
+}
+
+@OptIn(ExperimentalForeignApi::class)
+actual suspend fun platformClearWebviewData(pluginId: String?) {
+ if (pluginId == null) return
+
+ val dataStore = WKWebsiteDataStore.defaultDataStore()
+ val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
+ dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
+
+ NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
}
\ No newline at end of file
diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt
index eff8380e..2742885f 100644
--- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt
+++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt
@@ -1,31 +1,42 @@
-/*
- * 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.webview
-
-import dev.krtirtho.spotube.core.paths.Paths
-import io.github.kdroidfilter.webview.web.WebViewState
-import io.github.vinceglb.filekit.utils.div
-import io.github.vinceglb.filekit.utils.toPath
-import org.koin.core.context.GlobalContext
-
-actual fun platformWebviewConfig(webView: WebViewState) {
- val paths = GlobalContext.get().get()
-
- webView.webSettings.desktopWebSettings.dataDirectory =
- (paths.getApplicationCacheDirPath().toPath() / "webview_data").toString()
+/*
+ * 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.webview
+
+import dev.krtirtho.spotube.core.paths.Paths
+import io.github.kdroidfilter.webview.web.WebViewState
+import okio.FileSystem
+import okio.Path.Companion.toPath
+import org.koin.core.context.GlobalContext
+
+actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
+ val paths = GlobalContext.get().get()
+
+ val baseDir = "${paths.getApplicationCacheDirPath()}/webview_data".toPath()
+ val dataDir = if (pluginId != null) baseDir / pluginId else baseDir
+ webView.webSettings.desktopWebSettings.dataDirectory = dataDir.toString()
+}
+
+actual suspend fun platformClearWebviewData(pluginId: String?) {
+ if (pluginId == null) return
+ val paths = GlobalContext.get().get()
+ val dataDirStr = "${paths.getApplicationCacheDirPath()}/webview_data/$pluginId"
+ val dataDir = dataDirStr.toPath()
+ if (FileSystem.SYSTEM.exists(dataDir)) {
+ FileSystem.SYSTEM.deleteRecursively(dataDir)
+ }
}
\ No newline at end of file