Compare commits

...

2 Commits

9 changed files with 744 additions and 46 deletions

View File

@ -27,7 +27,11 @@ val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
PluginCapability.NETWORK_REQUESTS,
PluginCapability.PERSISTENT_STORAGE
),
abilities = listOf(PluginAbility.AUDIO)
abilities = listOf(PluginAbility.AUDIO),
contact = "",
repository = "",
bugs = "",
license = "",
)
val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
name = "LRCLib Lyrics",
@ -38,7 +42,11 @@ val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
capabilities = listOf(
PluginCapability.NETWORK_REQUESTS,
),
abilities = listOf(PluginAbility.LYRICS)
abilities = listOf(PluginAbility.LYRICS),
contact = "",
repository = "",
bugs = "",
license = "",
)
val BUILT_IN_PLUGINS = listOf(
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,

View File

@ -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<GitHubAsset>,
@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<GitHubRelease> {
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()
}

View File

@ -61,9 +61,9 @@ class PluginDiscoverViewModel(
viewModelScope.launch {
pluginManager.state.collect { pluginState ->
if (pluginState is PluginManagerStates.Data) {
val installedAuthors = pluginState.plugins.map { it.author }.toSet()
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
_state.update {
it.copy(repos = _allRepos.filter { repo -> repo.owner.login !in installedAuthors })
it.copy(repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls })
}
}
}
@ -74,8 +74,8 @@ class PluginDiscoverViewModel(
private fun filterInstalled(repos: List<GitHubRepo>): List<GitHubRepo> {
val pluginState = pluginManager.state.value
if (pluginState !is PluginManagerStates.Data) return repos
val installedAuthors = pluginState.plugins.map { it.author }.toSet()
return repos.filter { it.owner.login !in installedAuthors }
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
return repos.filter { it.htmlUrl !in installedUrls }
}
private fun loadFirstPage() {
@ -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<GitHubRelease> {
return gitHubRepo.getReleases(owner, repo)
}
override fun onCleared() {
gitHubRepo.close()
super.onCleared()

View File

@ -325,6 +325,15 @@ class PluginManager(
throw IllegalArgumentException("Invalid plugin.json format: ${e.message}")
}
// Preserve logo.png before deleting temp dir so the permission dialog can show it
val logoPngPath = tempDir / "logo.png".toPath()
if (FileSystem.SYSTEM.exists(logoPngPath)) {
val logoDir = pluginsDirPath / pluginEntry.id.toPath()
if (!FileSystem.SYSTEM.exists(logoDir)) FileSystem.SYSTEM.createDirectories(logoDir)
val destLogo = logoDir / "logo.png".toPath()
FileSystem.SYSTEM.copy(logoPngPath, destLogo)
}
pendingPlugin.value = buildPendingPlugin(pluginEntry, bytes)
} catch (e: Exception) {
throw Exception("Failed to read plugin: ${e.message}", e)

View File

@ -51,7 +51,11 @@ data class PluginEntry(
val description: String,
val author: String,
val capabilities: List<PluginCapability>,
val abilities: List<PluginAbility>
val abilities: List<PluginAbility>,
val contact: String,
val repository: String,
val bugs: String,
val license: String,
) {
@Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD")
val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray())

View File

@ -17,6 +17,7 @@
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
@ -30,6 +31,8 @@ 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
@ -41,6 +44,7 @@ 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
@ -50,16 +54,24 @@ 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
@ -67,21 +79,22 @@ 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.openUrlInBrowser
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.IconsaxDocumentDownload
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
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
@ -92,20 +105,9 @@ 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 androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.platform.LocalDensity
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import dev.krtirtho.spotube.core.extras.kebabToTitleCase
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo
import okio.SYSTEM
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
@ -164,6 +166,15 @@ fun PluginScreen(
val discoverViewModel: PluginDiscoverViewModel = koinViewModel()
val discoverState by discoverViewModel.state.collectAsStateWithLifecycle()
var showPluginInfo by remember { mutableStateOf<PluginEntry?>(null) }
var showPluginSupport by remember { mutableStateOf<PluginEntry?>(null) }
var supportText by remember { mutableStateOf<String?>(null) }
var isLoadingSupport by remember { mutableStateOf(false) }
var installDialogRepo by remember { mutableStateOf<GitHubRepo?>(null) }
var releases by remember { mutableStateOf<List<GitHubRelease>>(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)
@ -203,9 +214,8 @@ fun PluginScreen(
}
pendingPlugin?.let { pending ->
val logoPath = remember(pending.existingEntry?.id) {
val existingId = pending.existingEntry?.id ?: return@remember null
val path = pluginManager.pluginsDirPath / existingId.toPath() / "logo.png".toPath()
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(
@ -224,6 +234,160 @@ fun PluginScreen(
)
}
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 },
@ -539,6 +703,23 @@ fun PluginScreen(
},
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 {
@ -752,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) {
@ -823,6 +1013,78 @@ fun PluginScreen(
}
}
@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<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)
)
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,

View File

@ -52,6 +52,8 @@ import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxTag
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.User
@ -76,6 +78,8 @@ internal fun PluginCard(
isLoggedIn: Boolean,
onLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null,
onInfo: (() -> Unit)? = null,
onSupport: (() -> Unit)? = null,
logoPath: Path? = null,
) {
Row(
@ -234,28 +238,50 @@ internal fun PluginCard(
Column(
modifier = Modifier.align(Alignment.Bottom),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (plugin in BUILT_IN_PLUGINS) {
// Built-in plugins cannot be removed
Text(
stringResource(Res.string.plugin_state_builtin),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(6.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (onInfo != null) {
GhostIconButton(onClick = onInfo) {
Icon(
Iconsax.IconsaxInformation,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
} else {
GhostIconButton(onClick = onRemove) {
Icon(
Iconsax.IconsaxTrash,
contentDescription = stringResource(Res.string.plugin_action_remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
}
}
if (onSupport != null) {
GhostIconButton(onClick = onSupport) {
Icon(
Iconsax.IconsaxHeart,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (plugin in BUILT_IN_PLUGINS) {
Text(
stringResource(Res.string.plugin_state_builtin),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(6.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
} else {
GhostIconButton(onClick = onRemove) {
Icon(
Iconsax.IconsaxTrash,
contentDescription = stringResource(Res.string.plugin_action_remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<GitHubRelease>,
isLoadingReleases: Boolean,
onDismiss: () -> Unit,
onInstall: (GitHubRelease) -> Unit,
) {
var selectedRelease by remember { mutableStateOf<GitHubRelease?>(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
)
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,97 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxInformation: ImageVector
get() {
if (_IconsaxInformation != null) {
return _IconsaxInformation!!
}
_IconsaxInformation = ImageVector.Builder(
name = "IconsaxInformation",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(10.75f, 2.45f)
curveTo(11.45f, 1.86f, 12.58f, 1.86f, 13.26f, 2.45f)
lineTo(14.84f, 3.8f)
curveTo(15.14f, 4.05f, 15.71f, 4.26f, 16.11f, 4.26f)
horizontalLineTo(17.81f)
curveTo(18.87f, 4.26f, 19.74f, 5.13f, 19.74f, 6.19f)
verticalLineTo(7.89f)
curveTo(19.74f, 8.29f, 19.95f, 8.85f, 20.2f, 9.15f)
lineTo(21.55f, 10.73f)
curveTo(22.14f, 11.43f, 22.14f, 12.56f, 21.55f, 13.24f)
lineTo(20.2f, 14.82f)
curveTo(19.95f, 15.12f, 19.74f, 15.68f, 19.74f, 16.08f)
verticalLineTo(17.78f)
curveTo(19.74f, 18.84f, 18.87f, 19.71f, 17.81f, 19.71f)
horizontalLineTo(16.11f)
curveTo(15.71f, 19.71f, 15.15f, 19.92f, 14.85f, 20.17f)
lineTo(13.27f, 21.52f)
curveTo(12.57f, 22.11f, 11.44f, 22.11f, 10.76f, 21.52f)
lineTo(9.18f, 20.17f)
curveTo(8.88f, 19.92f, 8.31f, 19.71f, 7.92f, 19.71f)
horizontalLineTo(6.17f)
curveTo(5.11f, 19.71f, 4.24f, 18.84f, 4.24f, 17.78f)
verticalLineTo(16.07f)
curveTo(4.24f, 15.68f, 4.04f, 15.11f, 3.79f, 14.82f)
lineTo(2.44f, 13.23f)
curveTo(1.86f, 12.54f, 1.86f, 11.42f, 2.44f, 10.73f)
lineTo(3.79f, 9.14f)
curveTo(4.04f, 8.84f, 4.24f, 8.28f, 4.24f, 7.89f)
verticalLineTo(6.2f)
curveTo(4.24f, 5.14f, 5.11f, 4.27f, 6.17f, 4.27f)
horizontalLineTo(7.9f)
curveTo(8.3f, 4.27f, 8.86f, 4.06f, 9.16f, 3.81f)
lineTo(10.75f, 2.45f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 16.869f)
curveTo(11.45f, 16.869f, 11f, 16.419f, 11f, 15.869f)
curveTo(11f, 15.319f, 11.44f, 14.869f, 12f, 14.869f)
curveTo(12.55f, 14.869f, 13f, 15.319f, 13f, 15.869f)
curveTo(13f, 16.419f, 12.56f, 16.869f, 12f, 16.869f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 13.721f)
curveTo(11.59f, 13.721f, 11.25f, 13.381f, 11.25f, 12.971f)
verticalLineTo(8.131f)
curveTo(11.25f, 7.721f, 11.59f, 7.381f, 12f, 7.381f)
curveTo(12.41f, 7.381f, 12.75f, 7.721f, 12.75f, 8.131f)
verticalLineTo(12.961f)
curveTo(12.75f, 13.381f, 12.42f, 13.721f, 12f, 13.721f)
close()
}
}
}.build()
return _IconsaxInformation!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxInformation: ImageVector? = null