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

View File

@ -55,8 +55,12 @@ data class GitHubOwner(
@Serializable @Serializable
data class GitHubRelease( data class GitHubRelease(
@SerialName("tag_name") val tagName: String, @SerialName("tag_name") val tagName: String,
val name: String? = null,
val body: String? = null,
val assets: List<GitHubAsset>, val assets: List<GitHubAsset>,
@SerialName("html_url") val htmlUrl: String, @SerialName("html_url") val htmlUrl: String,
val prerelease: Boolean = false,
val draft: Boolean = false,
) )
@Serializable @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() { fun close() {
httpClient.close() httpClient.close()
} }

View File

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

View File

@ -325,6 +325,15 @@ class PluginManager(
throw IllegalArgumentException("Invalid plugin.json format: ${e.message}") 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) pendingPlugin.value = buildPendingPlugin(pluginEntry, bytes)
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Failed to read plugin: ${e.message}", e) throw Exception("Failed to read plugin: ${e.message}", e)

View File

@ -51,7 +51,11 @@ data class PluginEntry(
val description: String, val description: String,
val author: String, val author: String,
val capabilities: List<PluginCapability>, 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") @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD")
val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray()) val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray())

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.modules.plugin package dev.krtirtho.spotube.modules.plugin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -41,6 +44,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -50,16 +54,24 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.PlatformType
import dev.krtirtho.spotube.core.extras.kebabToTitleCase
import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton 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.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.base.TextField 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.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem 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.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.getPlatform 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.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle 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.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.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk 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.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic 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 io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.swiftzer.semver.SemVer
import okio.FileSystem import okio.FileSystem
import okio.Path.Companion.toPath 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 okio.SYSTEM
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
@ -164,6 +166,15 @@ fun PluginScreen(
val discoverViewModel: PluginDiscoverViewModel = koinViewModel() val discoverViewModel: PluginDiscoverViewModel = koinViewModel()
val discoverState by discoverViewModel.state.collectAsStateWithLifecycle() 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 pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme) val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
val downloadFailed = stringResource(Res.string.plugin_error_download_failed) val downloadFailed = stringResource(Res.string.plugin_error_download_failed)
@ -203,9 +214,8 @@ fun PluginScreen(
} }
pendingPlugin?.let { pending -> pendingPlugin?.let { pending ->
val logoPath = remember(pending.existingEntry?.id) { val logoPath = remember(pending.entry.id) {
val existingId = pending.existingEntry?.id ?: return@remember null val path = pluginManager.pluginsDirPath / pending.entry.id.toPath() / "logo.png".toPath()
val path = pluginManager.pluginsDirPath / existingId.toPath() / "logo.png".toPath()
if (FileSystem.SYSTEM.exists(path)) path else null if (FileSystem.SYSTEM.exists(path)) path else null
} }
PluginPermissionDialog( 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) { if (showInstallSheet) {
AdaptiveDialogBottomSheet( AdaptiveDialogBottomSheet(
onDismiss = { showInstallSheet = false }, onDismiss = { showInstallSheet = false },
@ -539,6 +703,23 @@ fun PluginScreen(
}, },
isLoggedIn = isLoggedIn, isLoggedIn = isLoggedIn,
logoPath = logoPath, 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) { onLogin = if (requiresAuth && selectedService != null) {
{ {
pluginManager.launchTask { pluginManager.launchTask {
@ -752,7 +933,16 @@ fun PluginScreen(
} }
} }
SecondaryButton( 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 enabled = !isInstalling
) { ) {
if (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 @Composable
fun DefaultAbilityPluginSelector( fun DefaultAbilityPluginSelector(
ability: PluginAbility, 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare 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.IconsaxTag
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
@ -76,6 +78,8 @@ internal fun PluginCard(
isLoggedIn: Boolean, isLoggedIn: Boolean,
onLogin: (() -> Unit)? = null, onLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null, onLogout: (() -> Unit)? = null,
onInfo: (() -> Unit)? = null,
onSupport: (() -> Unit)? = null,
logoPath: Path? = null, logoPath: Path? = null,
) { ) {
Row( Row(
@ -234,28 +238,50 @@ internal fun PluginCard(
Column( Column(
modifier = Modifier.align(Alignment.Bottom), modifier = Modifier.align(Alignment.Bottom),
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (plugin in BUILT_IN_PLUGINS) { Row(
// Built-in plugins cannot be removed horizontalArrangement = Arrangement.spacedBy(2.dp),
Text( verticalAlignment = Alignment.CenterVertically
stringResource(Res.string.plugin_state_builtin), ) {
style = MaterialTheme.typography.labelSmall, if (onInfo != null) {
color = MaterialTheme.colorScheme.onSurfaceVariant, GhostIconButton(onClick = onInfo) {
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) Icon(
.border( Iconsax.IconsaxInformation,
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), contentDescription = null,
shape = RoundedCornerShape(6.dp) tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
.padding(horizontal = 6.dp, vertical = 2.dp) }
) }
} else { if (onSupport != null) {
GhostIconButton(onClick = onRemove) { GhostIconButton(onClick = onSupport) {
Icon( Icon(
Iconsax.IconsaxTrash, Iconsax.IconsaxHeart,
contentDescription = stringResource(Res.string.plugin_action_remove), contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, 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