From 7b506b49e6acab7294871e7974f134605b0bb542 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 16 Aug 2026 14:19:01 +0600 Subject: [PATCH] refactor(plugin): replace PluginDiscoverViewModel with PluginViewModel and update navigation --- .../dev/krtirtho/spotube/core/di/Modules.kt | 4 +- .../core/navigation/NavigationModule.kt | 2 +- .../modules/plugin/PluginDiscoverViewModel.kt | 180 -- .../spotube/modules/plugin/PluginScreen.kt | 2450 ++++++++--------- .../spotube/modules/plugin/PluginViewModel.kt | 623 +++++ 5 files changed, 1798 insertions(+), 1461 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 4517e63b..665f351b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -54,9 +54,9 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel -import dev.krtirtho.spotube.modules.plugin.PluginDiscoverViewModel import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginProvider +import dev.krtirtho.spotube.modules.plugin.PluginViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.search.SearchRepository @@ -110,7 +110,7 @@ val sharedModules = module { // Plugin system singleOf(::PluginManager) { bind() } - viewModelOf(::PluginDiscoverViewModel) + viewModelOf(::PluginViewModel) // Settings singleOf(::SettingsRepository) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index 039e17c6..d048c1d8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -93,7 +93,7 @@ val navigationModule = module { SettingsScreen(settingsViewModel = koinViewModel()) } navigation { - PluginScreen(pluginManager = get()) + PluginScreen(viewModel = koinViewModel()) } navigation {} navigation { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt deleted file mode 100644 index 68c78ba0..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.modules.plugin - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.di.injectLogger -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent - -data class PluginDiscoverState( - val repos: List = emptyList(), - val currentPage: Int = 1, - val hasMore: Boolean = true, - val isLoading: Boolean = false, - val isLoadingMore: Boolean = false, - val error: String? = null, - val installingRepoId: Long? = null, - val isInitialLoaded: Boolean = false, -) - -class PluginDiscoverViewModel( - private val pluginManager: PluginManager, -) : ViewModel(), KoinComponent { - - private val logger by injectLogger() - private val gitHubRepo = GitHubPluginRepository() - - private val _allRepos = mutableListOf() - private val _paginationInfo = PaginationInfo() - - private val _state = MutableStateFlow(PluginDiscoverState()) - val state: StateFlow = _state.asStateFlow() - - private class PaginationInfo( - var currentPage: Int = 1, - var hasMore: Boolean = true, - var totalCount: Int = 0, - ) - - init { - viewModelScope.launch { - pluginManager.state.collect { pluginState -> - if (pluginState is PluginManagerStates.Data) { - val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet() - _state.update { - it.copy(repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls }) - } - } - } - } - loadFirstPage() - } - - private fun filterInstalled(repos: List): List { - val pluginState = pluginManager.state.value - if (pluginState !is PluginManagerStates.Data) return repos - val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet() - return repos.filter { it.htmlUrl !in installedUrls } - } - - private fun loadFirstPage() { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - runCatching { - gitHubRepo.searchSpotubePlugins(page = 1) - }.onSuccess { response -> - _allRepos.clear() - _allRepos.addAll(response.items) - _paginationInfo.currentPage = 1 - _paginationInfo.totalCount = response.totalCount - _paginationInfo.hasMore = _allRepos.size < response.totalCount - _state.update { - it.copy( - repos = filterInstalled(response.items), - currentPage = 1, - hasMore = _paginationInfo.hasMore, - isLoading = false, - isInitialLoaded = true, - ) - } - }.onFailure { e -> - logger.e(e) { "Failed to load plugins" } - _state.update { - it.copy( - isLoading = false, - error = e.message, - isInitialLoaded = true, - ) - } - } - } - } - - fun loadNextPage() { - val current = _state.value - if (current.isLoadingMore || !current.hasMore) return - viewModelScope.launch { - val nextPage = _paginationInfo.currentPage + 1 - _state.update { it.copy(isLoadingMore = true, error = null) } - runCatching { - gitHubRepo.searchSpotubePlugins(page = nextPage) - }.onSuccess { response -> - _allRepos.addAll(response.items) - _paginationInfo.currentPage = nextPage - _paginationInfo.hasMore = _allRepos.size < response.totalCount - _state.update { - it.copy( - repos = filterInstalled(_allRepos), - currentPage = nextPage, - hasMore = _paginationInfo.hasMore, - isLoadingMore = false, - ) - } - }.onFailure { e -> - logger.e(e) { "Failed to load more plugins" } - _state.update { it.copy(isLoadingMore = false, error = e.message) } - } - } - } - - fun installPlugin(repo: GitHubRepo) { - if (_state.value.installingRepoId != null) return - _state.update { it.copy(installingRepoId = repo.id, error = null) } - viewModelScope.launch { - runCatching { - val parts = repo.fullName.split("/") - val url = gitHubRepo.getLatestReleaseSmplugUrl(parts[0], parts[1]) - ?: throw IllegalStateException("No .smplug asset found in latest release") - pluginManager.addPluginFromURL(url) - }.onFailure { e -> - logger.e(e) { "Failed to install plugin" } - _state.update { it.copy(error = e.message) } - } - _state.update { it.copy(installingRepoId = null) } - } - } - - fun installPluginFromUrl(url: String, repoId: Long) { - if (_state.value.installingRepoId != null) return - _state.update { it.copy(installingRepoId = repoId, error = null) } - viewModelScope.launch { - runCatching { - pluginManager.addPluginFromURL(url) - }.onFailure { e -> - logger.e(e) { "Failed to install plugin" } - _state.update { it.copy(error = e.message) } - } - _state.update { it.copy(installingRepoId = null) } - } - } - - suspend fun getReleases(owner: String, repo: String): List { - return gitHubRepo.getReleases(owner, repo) - } - - override fun onCleared() { - gitHubRepo.close() - super.onCleared() - } -} 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 6f0029a9..031397ed 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,1278 +1,1172 @@ -/* - * 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 - - try { - service.use { - val pluginRequiresAuth = - coreAPI.requiresAuthentication - requiresAuth = pluginRequiresAuth - if (!pluginRequiresAuth) return@use - - coreAPI.loggedInFlow.collect { loggedIn -> - isLoggedIn = loggedIn - } - } - } catch (_: Exception) { - // Service may have been stopped/closed - // concurrently when the plugin selection changed - } - } - - 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) - } -} - +/* + * 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.remember +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.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 okio.Path +import org.jetbrains.compose.resources.stringResource +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( + viewModel: PluginViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + val platform = remember { getPlatform() } + val launcher = rememberFilePickerLauncher( + type = FileKitType.File( + extensions = if (platform.type == PlatformType.Android) listOf() else listOf("smplug") + ) + ) { file -> + viewModel.onFileSelected(file) + } + + val state = uiState + if (state is PluginUiState.Data) { + state.pendingPlugin?.let { pending -> + PluginPermissionDialog( + pluginInfo = pending.entry, + title = pending.title, + message = pending.message, + confirmLabel = pending.confirmLabel, + existingPlugin = pending.existingEntry, + logoPath = state.pendingPluginLogoPath, + onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) { + { viewModel.confirmInstall() } + } else { + null + }, + onDismiss = { viewModel.dismissInstall() } + ) + } + + state.showPluginInfo?.let { plugin -> + PluginInfoDialog( + plugin = plugin, + logoPath = state.pluginInfoLogoPath, + onDismiss = { viewModel.dismissPluginInfo() } + ) + } + + state.showPluginSupport?.let { plugin -> + SupportDialog( + plugin = plugin, + supportText = state.supportText, + isLoading = state.isLoadingSupport, + onDismiss = { viewModel.dismissSupport() } + ) + } + + state.installDialogRepo?.let { repo -> + PluginInstallDialog( + repo = repo, + releases = state.releases, + isLoadingReleases = state.isLoadingReleases, + onDismiss = { viewModel.dismissInstallDialog() }, + onInstall = { release -> + viewModel.dismissInstallDialog() + viewModel.installPlugin(release, repo.id) + } + ) + } + + if (state.showInstallSheet) { + InstallSheet( + urlInput = state.urlInput, + urlError = state.urlError, + isLoadingUrl = state.isLoadingUrl, + onUrlChange = { viewModel.onUrlInputChange(it) }, + onSubmitUrl = { viewModel.submitUrl() }, + onPickFile = { launcher.launch() }, + onDismiss = { viewModel.dismissInstallSheet() } + ) + } + } + + Scaffold( + topBar = { + ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) + } + ) { innerPadding -> + when (val data = uiState) { + is PluginUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } + } + + is PluginUiState.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) + ) { + 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 = { viewModel.showInstallSheet() }) { + Icon( + Iconsax.IconsaxAdd, + contentDescription = "Install a plugin", + ) + Text(stringResource(Res.string.plugin_install_section_title)) + } + } + } + + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 4.dp) + ) { + data.abilitySelections.forEachIndexed { index, selection -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy( + alpha = 0.5f + ), + ) + } + DefaultAbilityPluginSelector( + selection = selection, + onSelected = { plugin -> + viewModel.selectPlugin(selection.ability, plugin) + }, + ) + } + } + } + } + + if (data.plugins.isEmpty()) { + item { + Box( + 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 { + item { + val noun = if (data.plugins.size == 1) { + stringResource(Res.string.plugin_installed_singular) + } else { + stringResource(Res.string.plugin_installed_plural) + } + Text( + stringResource( + Res.string.plugin_installed_count, + data.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() + ) { + data.plugins.forEachIndexed { index, item -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy( + alpha = 0.5f + ), + ) + } + PluginCard( + plugin = item.plugin, + isSelected = item.isSelected, + onRemove = { viewModel.removePlugin(item.plugin) }, + isLoggedIn = item.authState.isLoggedIn, + logoPath = item.logoPath, + onInfo = { viewModel.showPluginInfo(item.plugin) }, + onSupport = if (item.isSelected) { + { viewModel.loadSupport(item.plugin) } + } else null, + onLogin = if (item.isSelected && item.authState.requiresAuth) { + { viewModel.login(item.plugin) } + } else null, + onLogout = if (item.isSelected && item.authState.requiresAuth) { + { viewModel.logout(item.plugin) } + } else null, + ) + } + } + } + } + } + + if (data.discover.isLoading || data.discover.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( + data.discover.repos, + key = { it.id } + ) { repo -> + DiscoverRepoCard( + repo = repo, + isInstalling = data.discover.installingRepoId == repo.id, + onInstall = { viewModel.showInstallDialog(repo) } + ) + } + + if (data.discover.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + + data.discover.error?.let { error -> + item { + Text( + 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 + } + } + + // Pagination trigger stays in the composable because it is driven by + // LazyList layout information, which only exists in UI scope. + // The actual loading call is forwarded to the ViewModel. + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value) { + viewModel.loadNextDiscoverPage() + } + } + } + } + } + } +} + +@Composable +private fun UrlError.text(): String = when (this) { + is UrlError.Empty -> stringResource(Res.string.plugin_error_enter_url) + is UrlError.InvalidScheme -> stringResource(Res.string.plugin_error_url_scheme) + is UrlError.DownloadFailed -> stringResource(Res.string.plugin_error_download_failed) + is UrlError.Message -> message +} + +@Composable +private fun InstallSheet( + urlInput: String, + urlError: UrlError?, + isLoadingUrl: Boolean, + onUrlChange: (String) -> Unit, + onSubmitUrl: () -> Unit, + onPickFile: () -> Unit, + onDismiss: () -> Unit, +) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + 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 = onUrlChange, + modifier = Modifier.weight(1f), + placeholder = { + Text( + stringResource(Res.string.plugin_url_placeholder), + style = MaterialTheme.typography.bodySmall + ) + }, + leadingIcon = { + Icon( + Iconsax.IconsaxLink, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + isError = urlError != null, + singleLine = true, + ) + SecondaryIconButton( + onClick = onSubmitUrl, + enabled = !isLoadingUrl, + ) { + if (isLoadingUrl) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = stringResource(Res.string.plugin_action_download), + ) + } + } + } + + urlError?.let { error -> + Text( + error.text(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + 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 = onPickFile, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Iconsax.IconsaxExportArrowBulk, + contentDescription = stringResource(Res.string.plugin_action_install_from_file) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.plugin_action_install_from_file)) + } + } + } +} + +@Composable +private fun PluginInfoDialog( + plugin: PluginEntry, + logoPath: Path?, + onDismiss: () -> Unit, +) { + ThemedDialog( + onDismissRequest = onDismiss, + 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 = onDismiss) { + 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) + } + } + } + } +} + +@Composable +private fun SupportDialog( + plugin: PluginEntry, + supportText: String?, + isLoading: Boolean, + onDismiss: () -> Unit, +) { + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text( + "Support ${plugin.name}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + }, + actions = { + PrimaryButton(onClick = onDismiss) { + Text("Close") + } + } + ) { + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + Text( + supportText ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun DiscoverRepoCard( + repo: GitHubRepo, + isInstalling: Boolean, + onInstall: () -> Unit, +) { + val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS + val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS + + 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 = onInstall, + 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)) + } + } + } +} + +@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( + selection: AbilitySelection, + onSelected: (PluginEntry?) -> Unit = { }, +) { + val plugins = selection.plugins + val selectedPlugin = selection.selectedPlugin + 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 (selection.ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) + PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) + PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) + } + ) { + Icon( + imageVector = when (selection.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, + selection.ability.displayLabel() + ), + modifier = Modifier.padding(8.dp), + tint = when (selection.ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50) + PluginAbility.AUDIO -> Color(0xFF2196F3) + PluginAbility.LYRICS -> Color(0xFFFFC107) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0) + } + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + selection.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 (selection.ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) + PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) + PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) + } + ) { + Icon( + imageVector = when (selection.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 (selection.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, + selection.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/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt new file mode 100644 index 00000000..004580f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt @@ -0,0 +1,623 @@ +/* + * 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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.webview.WebViewController +import dev.krtirtho.spotube.core.zipline.PluginService +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.swiftzer.semver.SemVer +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import org.koin.core.component.KoinComponent + +data class PluginAuthState( + val requiresAuth: Boolean = false, + val isLoggedIn: Boolean = false, +) + +data class PluginListItem( + val plugin: PluginEntry, + val isSelected: Boolean, + val authState: PluginAuthState, + val logoPath: Path?, +) + +data class AbilitySelection( + val ability: PluginAbility, + val plugins: List, + val selectedPlugin: PluginEntry?, +) + +data class PluginDiscoverState( + val repos: List = emptyList(), + val currentPage: Int = 1, + val hasMore: Boolean = true, + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val error: String? = null, + val installingRepoId: Long? = null, + val isInitialLoaded: Boolean = false, +) + +sealed interface UrlError { + data object Empty : UrlError + data object InvalidScheme : UrlError + data object DownloadFailed : UrlError + data class Message(val message: String) : UrlError +} + +sealed interface PluginUiState { + data object Loading : PluginUiState + + data class Data( + val plugins: List, + val abilitySelections: List, + val pendingPlugin: PluginManager.PendingPlugin? = null, + val pendingPluginLogoPath: Path? = null, + val urlInput: String = "", + val urlError: UrlError? = null, + val isLoadingUrl: Boolean = false, + val showInstallSheet: Boolean = false, + val showPluginInfo: PluginEntry? = null, + val pluginInfoLogoPath: Path? = null, + val showPluginSupport: PluginEntry? = null, + val supportText: String? = null, + val isLoadingSupport: Boolean = false, + val installDialogRepo: GitHubRepo? = null, + val releases: List = emptyList(), + val isLoadingReleases: Boolean = false, + val discover: PluginDiscoverState = PluginDiscoverState(), + ) : PluginUiState +} + +private data class PluginManagerSnapshot( + val state: PluginManagerStates, + val pendingPlugin: PluginManager.PendingPlugin?, + val metadataPlugins: List, + val audioPlugins: List, + val lyricsPlugins: List, + val scrobblePlugins: List, + val activeServices: Map?, +) + +private data class PluginScreenState( + val urlInput: String = "", + val urlError: UrlError? = null, + val isLoadingUrl: Boolean = false, + val showInstallSheet: Boolean = false, + val showPluginInfo: PluginEntry? = null, + val pluginInfoLogoPath: Path? = null, + val showPluginSupport: PluginEntry? = null, + val supportText: String? = null, + val isLoadingSupport: Boolean = false, + val installDialogRepo: GitHubRepo? = null, + val releases: List = emptyList(), + val isLoadingReleases: Boolean = false, + val discover: PluginDiscoverState = PluginDiscoverState(), +) + +@OptIn(ExperimentalCoroutinesApi::class) +class PluginViewModel( + private val pluginManager: PluginManager, + private val webViewController: WebViewController, +) : ViewModel(), KoinComponent { + + private val logger by injectLogger() + private val gitHubRepo = GitHubPluginRepository() + + private val _screenState = MutableStateFlow(PluginScreenState()) + private val _allRepos = mutableListOf() + private val _paginationInfo = PaginationInfo() + + init { + viewModelScope.launch { + pluginManager.state.collect { pluginState -> + if (pluginState is PluginManagerStates.Data) { + val installedUrls = pluginState.plugins + .mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } } + .toSet() + _screenState.update { + it.copy( + discover = it.discover.copy( + repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls } + ) + ) + } + } + } + } + loadFirstDiscoverPage() + } + + private class PaginationInfo( + var currentPage: Int = 1, + var hasMore: Boolean = true, + var totalCount: Int = 0, + ) + + @Suppress("UNCHECKED_CAST") + private val pluginManagerSnapshot: StateFlow = combine( + pluginManager.state, + pluginManager.pendingPlugin, + pluginManager.metadataPlugins, + pluginManager.audioPlugins, + pluginManager.lyricsPlugins, + pluginManager.scrobblePlugins, + pluginManager.ziplineServices, + ) { values -> + PluginManagerSnapshot( + state = values[0] as PluginManagerStates, + pendingPlugin = values[1] as PluginManager.PendingPlugin?, + metadataPlugins = values[2] as List, + audioPlugins = values[3] as List, + lyricsPlugins = values[4] as List, + scrobblePlugins = values[5] as List, + activeServices = values[6] as Map?, + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + PluginManagerSnapshot( + state = PluginManagerStates.Loading, + pendingPlugin = null, + metadataPlugins = emptyList(), + audioPlugins = emptyList(), + lyricsPlugins = emptyList(), + scrobblePlugins = emptyList(), + activeServices = null, + ) + ) + + private val pluginLogoPaths: StateFlow> = pluginManager.state + .filterIsInstance() + .map { state -> + state.plugins.associate { plugin -> + plugin.id to getLogoPath(plugin.id) + } + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + private val pluginAuthStates: StateFlow> = pluginManager.state + .filterIsInstance() + .map { it.plugins } + .distinctUntilChanged() + .flatMapLatest { plugins -> + if (plugins.isEmpty()) return@flatMapLatest flowOf(emptyMap()) + val entries = plugins.associate { plugin -> + plugin.id to pluginAuthFlow(plugin) + } + combine(entries.values.toList()) { states -> + entries.keys.zip(states).toMap() + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + val uiState: StateFlow = combine( + pluginManagerSnapshot, + _screenState, + pluginAuthStates, + pluginLogoPaths, + ) { snapshot, screen, authStates, logoPaths -> + when (val state = snapshot.state) { + is PluginManagerStates.Loading -> PluginUiState.Loading + is PluginManagerStates.Data -> PluginUiState.Data( + plugins = state.plugins.map { plugin -> + PluginListItem( + plugin = plugin, + isSelected = state.selectedPlugins.containsValue(plugin), + authState = authStates[plugin.id] ?: PluginAuthState(), + logoPath = logoPaths[plugin.id], + ) + }, + abilitySelections = listOf( + AbilitySelection( + PluginAbility.METADATA, + snapshot.metadataPlugins, + state.selectedPlugins[PluginAbility.METADATA] + ), + AbilitySelection( + PluginAbility.AUDIO, + snapshot.audioPlugins, + state.selectedPlugins[PluginAbility.AUDIO] + ), + AbilitySelection( + PluginAbility.LYRICS, + snapshot.lyricsPlugins, + state.selectedPlugins[PluginAbility.LYRICS] + ), + AbilitySelection( + PluginAbility.SCROBBLE, + snapshot.scrobblePlugins, + state.selectedPlugins[PluginAbility.SCROBBLE] + ), + ), + pendingPlugin = snapshot.pendingPlugin, + pendingPluginLogoPath = snapshot.pendingPlugin?.entry?.id?.let { getLogoPath(it) }, + urlInput = screen.urlInput, + urlError = screen.urlError, + isLoadingUrl = screen.isLoadingUrl, + showInstallSheet = screen.showInstallSheet, + showPluginInfo = screen.showPluginInfo, + pluginInfoLogoPath = screen.pluginInfoLogoPath, + showPluginSupport = screen.showPluginSupport, + supportText = screen.supportText, + isLoadingSupport = screen.isLoadingSupport, + installDialogRepo = screen.installDialogRepo, + releases = screen.releases, + isLoadingReleases = screen.isLoadingReleases, + discover = screen.discover, + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PluginUiState.Loading) + + private fun pluginAuthFlow(plugin: PluginEntry): Flow { + return combine(pluginManager.state, pluginManager.ziplineServices) { state, services -> + val data = state as? PluginManagerStates.Data + val ability = data?.selectedPlugins?.entries + ?.firstOrNull { (_, selected) -> selected.id == plugin.id } + ?.key + ability?.let { services?.get(it) } + }.distinctUntilChanged() + .flatMapLatest { service -> + if (service == null) return@flatMapLatest flowOf(PluginAuthState()) + flow { + val requiresAuth = try { + service.use { coreAPI.requiresAuthentication } + } catch (e: Exception) { + if (e is CancellationException) throw e + // Service may have been stopped/closed concurrently when the + // plugin selection changed. Fall back to a default state and wait + // for the next service emission. + false + } + emit(PluginAuthState(requiresAuth = requiresAuth, isLoggedIn = false)) + if (requiresAuth) { + service.loggedInFlow.collect { loggedIn -> + emit(PluginAuthState(requiresAuth = true, isLoggedIn = loggedIn)) + } + } + } + } + } + + private fun getLogoPath(pluginId: String): Path? { + val path = pluginManager.pluginsDirPath / pluginId.toPath() / "logo.png".toPath() + return if (FileSystem.SYSTEM.exists(path)) path else null + } + + private fun findAbilityForPlugin(pluginId: String): PluginAbility? { + val state = pluginManagerSnapshot.value.state as? PluginManagerStates.Data + return state?.selectedPlugins?.entries + ?.firstOrNull { (_, plugin) -> plugin.id == pluginId } + ?.key + } + + private fun findServiceForPlugin(pluginId: String): PluginService? { + val ability = findAbilityForPlugin(pluginId) ?: return null + return pluginManagerSnapshot.value.activeServices?.get(ability) + } + + fun onUrlInputChange(input: String) { + _screenState.update { it.copy(urlInput = input, urlError = null) } + } + + fun submitUrl() { + val url = _screenState.value.urlInput.trim() + when { + url.isBlank() -> { + _screenState.update { it.copy(urlError = UrlError.Empty) } + return + } + + !url.startsWith("http://") && !url.startsWith("https://") -> { + _screenState.update { it.copy(urlError = UrlError.InvalidScheme) } + return + } + } + + _screenState.update { it.copy(urlError = null, isLoadingUrl = true) } + viewModelScope.launch { + runCatching { + pluginManager.addPluginFromURL(url) + }.onSuccess { + _screenState.update { it.copy(urlInput = "", isLoadingUrl = false) } + }.onFailure { e -> + logger.e(e) { "Failed to add plugin from URL" } + _screenState.update { + it.copy( + urlError = e.message?.let { message -> UrlError.Message(message) } + ?: UrlError.DownloadFailed, + isLoadingUrl = false, + ) + } + } + } + } + + fun onFileSelected(file: PlatformFile?) { + if (file == null) return + viewModelScope.launch { + runCatching { + pluginManager.preparePlugin(file.readBytes()) + }.onFailure { e -> + logger.e(e) { "Failed to prepare plugin from file" } + } + } + } + + fun showInstallSheet() { + _screenState.update { it.copy(showInstallSheet = true) } + } + + fun dismissInstallSheet() { + _screenState.update { it.copy(showInstallSheet = false) } + } + + fun showPluginInfo(plugin: PluginEntry) { + _screenState.update { + it.copy( + showPluginInfo = plugin, + pluginInfoLogoPath = getLogoPath(plugin.id), + ) + } + } + + fun dismissPluginInfo() { + _screenState.update { it.copy(showPluginInfo = null, pluginInfoLogoPath = null) } + } + + fun loadSupport(plugin: PluginEntry) { + _screenState.update { + it.copy( + showPluginSupport = plugin, + isLoadingSupport = true, + supportText = null, + ) + } + viewModelScope.launch { + val service = findServiceForPlugin(plugin.id) + if (service == null) { + _screenState.update { it.copy(isLoadingSupport = false) } + return@launch + } + runCatching { + service.use { coreAPI.supportMarkdownText(SemVer.parse(plugin.version)) } + }.onSuccess { text -> + _screenState.update { it.copy(supportText = text, isLoadingSupport = false) } + }.onFailure { e -> + logger.e(e) { "Failed to load support text" } + _screenState.update { it.copy(isLoadingSupport = false) } + } + } + } + + fun dismissSupport() { + _screenState.update { + it.copy( + showPluginSupport = null, + supportText = null, + isLoadingSupport = false, + ) + } + } + + fun showInstallDialog(repo: GitHubRepo) { + _screenState.update { + it.copy( + installDialogRepo = repo, + isLoadingReleases = true, + releases = emptyList(), + ) + } + viewModelScope.launch { + runCatching { + val parts = repo.fullName.split("/") + gitHubRepo.getReleases(parts[0], parts[1]) + }.onSuccess { releases -> + _screenState.update { it.copy(releases = releases, isLoadingReleases = false) } + }.onFailure { e -> + logger.e(e) { "Failed to load releases" } + _screenState.update { it.copy(isLoadingReleases = false) } + } + } + } + + fun dismissInstallDialog() { + _screenState.update { it.copy(installDialogRepo = null, releases = emptyList()) } + } + + fun selectPlugin(ability: PluginAbility, plugin: PluginEntry?) { + pluginManager.setSelectedPlugin(ability, plugin) + } + + fun removePlugin(plugin: PluginEntry) { + viewModelScope.launch { + runCatching { + pluginManager.removePlugin(plugin) + }.onFailure { e -> + logger.e(e) { "Failed to remove plugin" } + } + } + } + + fun login(plugin: PluginEntry) { + val service = findServiceForPlugin(plugin.id) ?: return + pluginManager.launchTask { + service.use { coreAPI.login() } + } + } + + fun logout(plugin: PluginEntry) { + val service = findServiceForPlugin(plugin.id) ?: return + pluginManager.launchTask { + service.use { coreAPI.logout() } + } + viewModelScope.launch { + webViewController.clearData(plugin.id) + } + } + + fun confirmInstall() { + pluginManager.confirmInstall() + } + + fun dismissInstall() { + pluginManager.dismissInstall() + } + + private fun filterInstalledRepos(repos: List): List { + val pluginState = pluginManager.state.value + if (pluginState !is PluginManagerStates.Data) return repos + val installedUrls = pluginState.plugins + .mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } } + .toSet() + return repos.filter { it.htmlUrl !in installedUrls } + } + + private fun loadFirstDiscoverPage() { + viewModelScope.launch { + _screenState.update { + it.copy(discover = it.discover.copy(isLoading = true, error = null)) + } + runCatching { + gitHubRepo.searchSpotubePlugins(page = 1) + }.onSuccess { response -> + _allRepos.clear() + _allRepos.addAll(response.items) + _paginationInfo.currentPage = 1 + _paginationInfo.totalCount = response.totalCount + _paginationInfo.hasMore = _allRepos.size < response.totalCount + _screenState.update { + it.copy( + discover = it.discover.copy( + repos = filterInstalledRepos(response.items), + currentPage = 1, + hasMore = _paginationInfo.hasMore, + isLoading = false, + isInitialLoaded = true, + ) + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load plugins" } + _screenState.update { + it.copy( + discover = it.discover.copy( + isLoading = false, + error = e.message, + isInitialLoaded = true, + ) + ) + } + } + } + } + + fun loadNextDiscoverPage() { + val current = _screenState.value.discover + if (current.isLoadingMore || !current.hasMore) return + viewModelScope.launch { + val nextPage = _paginationInfo.currentPage + 1 + _screenState.update { + it.copy(discover = it.discover.copy(isLoadingMore = true, error = null)) + } + runCatching { + gitHubRepo.searchSpotubePlugins(page = nextPage) + }.onSuccess { response -> + _allRepos.addAll(response.items) + _paginationInfo.currentPage = nextPage + _paginationInfo.hasMore = _allRepos.size < response.totalCount + _screenState.update { + it.copy( + discover = it.discover.copy( + repos = filterInstalledRepos(_allRepos), + currentPage = nextPage, + hasMore = _paginationInfo.hasMore, + isLoadingMore = false, + ) + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more plugins" } + _screenState.update { + it.copy( + discover = it.discover.copy( + isLoadingMore = false, + error = e.message, + ) + ) + } + } + } + } + + fun installPlugin(release: GitHubRelease, repoId: Long) { + val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl + if (smplugUrl != null) { + installPluginFromUrl(smplugUrl, repoId) + } + } + + private fun installPluginFromUrl(url: String, repoId: Long) { + if (_screenState.value.discover.installingRepoId != null) return + _screenState.update { + it.copy(discover = it.discover.copy(installingRepoId = repoId, error = null)) + } + viewModelScope.launch { + runCatching { + pluginManager.addPluginFromURL(url) + }.onFailure { e -> + logger.e(e) { "Failed to install plugin" } + _screenState.update { + it.copy(discover = it.discover.copy(error = e.message)) + } + } + _screenState.update { + it.copy(discover = it.discover.copy(installingRepoId = null)) + } + } + } + + override fun onCleared() { + gitHubRepo.close() + super.onCleared() + } +}