From 4bc487782f76d8812c9c1cc405b300851ac88a28 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 25 Jul 2026 22:26:06 +0600 Subject: [PATCH] feat(plugin_install): add plugin installation dialog and release fetching functionality --- .../modules/plugin/GitHubPluginRepo.kt | 18 ++ .../modules/plugin/PluginDiscoverViewModel.kt | 18 ++ .../spotube/modules/plugin/PluginScreen.kt | 32 ++- .../plugin/components/PluginInstallDialog.kt | 256 ++++++++++++++++++ 4 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt index 285f4866..91ffc9b3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt @@ -55,8 +55,12 @@ data class GitHubOwner( @Serializable data class GitHubRelease( @SerialName("tag_name") val tagName: String, + val name: String? = null, + val body: String? = null, val assets: List, @SerialName("html_url") val htmlUrl: String, + val prerelease: Boolean = false, + val draft: Boolean = false, ) @Serializable @@ -101,6 +105,20 @@ class GitHubPluginRepository { } } + suspend fun getReleases(owner: String, repo: String, perPage: Int = 30): List { + return try { + httpClient.get("https://api.github.com/repos/$owner/$repo/releases") { + headers { + append("Accept", "application/vnd.github+json") + append("X-GitHub-Api-Version", "2022-11-28") + } + parameter("per_page", perPage) + }.body() + } catch (_: Exception) { + emptyList() + } + } + fun close() { httpClient.close() } 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 index bb79edb0..68c78ba0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt @@ -155,6 +155,24 @@ class PluginDiscoverViewModel( } } + 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 50d6a506..307011c5 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 @@ -80,6 +80,7 @@ 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 @@ -170,6 +171,10 @@ fun PluginScreen( 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) @@ -367,6 +372,22 @@ fun PluginScreen( } } + 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 }, @@ -912,7 +933,16 @@ fun PluginScreen( } } SecondaryButton( - onClick = { discoverViewModel.installPlugin(repo) }, + 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) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt new file mode 100644 index 00000000..81049853 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.plugin.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.modules.plugin.GitHubRepo +import dev.krtirtho.spotube.modules.plugin.GitHubRelease +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxBox +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PluginInstallDialog( + repo: GitHubRepo, + releases: List, + isLoadingReleases: Boolean, + onDismiss: () -> Unit, + onInstall: (GitHubRelease) -> Unit, +) { + var selectedRelease by remember { mutableStateOf(null) } + var expanded by remember { mutableStateOf(false) } + + LaunchedEffect(releases) { + if (selectedRelease == null && releases.isNotEmpty()) { + selectedRelease = releases.first() + } + } + + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + ) { + Box(contentAlignment = Alignment.Center) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(repo.owner.avatarUrl) + .crossfade(true) + .build(), + contentDescription = repo.owner.login, + modifier = Modifier.fillMaxSize() + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + repo.fullName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!repo.description.isNullOrBlank()) { + Text( + repo.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + }, + actions = { + PrimaryButton( + onClick = { selectedRelease?.let { onInstall(it) } }, + enabled = selectedRelease != null && !isLoadingReleases + ) { + Text("Install") + } + } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (isLoadingReleases) { + Box( + modifier = Modifier.fillMaxWidth().height(120.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } else if (releases.isEmpty()) { + Text( + "No releases found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Text( + "Select Release", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + selectedRelease?.tagName ?: "Select a release", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + } + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + releases.forEach { release -> + DropdownMenuItem( + text = { + Column { + Text( + release.tagName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (release == selectedRelease) FontWeight.SemiBold else FontWeight.Normal + ) + if (release.prerelease) { + Text( + "Pre-release", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + onClick = { + selectedRelease = release + expanded = false + } + ) + } + } + } + + selectedRelease?.let { release -> + if (!release.body.isNullOrBlank()) { + Text( + "Release Notes", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) + .padding(12.dp) + ) { + Text( + release.body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } +}