feat(plugin_discovery): implement GitHub plugin discovery and add logo support and information dialog in UI

This commit is contained in:
Kingkor Roy Tirtho 2026-07-25 22:06:13 +06:00
parent ec672d5f84
commit 160f1f318f
7 changed files with 421 additions and 45 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

@ -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() {

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,21 @@ 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.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 +104,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 +165,11 @@ 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) }
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 +209,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 +229,144 @@ 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
)
}
}
}
if (showInstallSheet) { if (showInstallSheet) {
AdaptiveDialogBottomSheet( AdaptiveDialogBottomSheet(
onDismiss = { showInstallSheet = false }, onDismiss = { showInstallSheet = false },
@ -539,6 +682,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 {
@ -823,6 +983,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,10 +238,31 @@ 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)
) { ) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (onInfo != null) {
GhostIconButton(onClick = onInfo) {
Icon(
Iconsax.IconsaxInformation,
contentDescription = null,
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) { if (plugin in BUILT_IN_PLUGINS) {
// Built-in plugins cannot be removed
Text( Text(
stringResource(Res.string.plugin_state_builtin), stringResource(Res.string.plugin_state_builtin),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@ -258,6 +283,7 @@ internal fun PluginCard(
) )
} }
} }
}
val authAction = when { val authAction = when {
isLoggedIn && onLogout != null -> onLogout to Res.string.plugin_action_logout isLoggedIn && onLogout != null -> onLogout to Res.string.plugin_action_logout

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