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

This commit is contained in:
Kingkor Roy Tirtho 2026-07-25 14:57:35 +06:00
parent 6c9c762b87
commit ec672d5f84
15 changed files with 737 additions and 15 deletions

View File

@ -17,7 +17,11 @@
package dev.krtirtho.spotube package dev.krtirtho.spotube
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import org.koin.core.context.GlobalContext
class AndroidPlatform : Platform { class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}" override val name: String = "Android ${Build.VERSION.SDK_INT}"
@ -25,3 +29,11 @@ class AndroidPlatform : Platform {
} }
actual fun getPlatform(): Platform = AndroidPlatform() actual fun getPlatform(): Platform = AndroidPlatform()
actual fun openUrlInBrowser(url: String) {
val context = GlobalContext.get().get<Context>()
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}

View File

@ -151,6 +151,7 @@
<string name="plugin_action_logout">Logout</string> <string name="plugin_action_logout">Logout</string>
<string name="plugin_section_url_title">Download from URL</string> <string name="plugin_section_url_title">Download from URL</string>
<string name="plugin_section_file_title">Install from file</string> <string name="plugin_section_file_title">Install from file</string>
<string name="plugin_section_install">Install</string>
<string name="plugin_permissions_author_version">• %1$s</string> <string name="plugin_permissions_author_version">• %1$s</string>
<string name="plugin_permissions_requested_title">Permissions requested</string> <string name="plugin_permissions_requested_title">Permissions requested</string>

View File

@ -28,6 +28,8 @@ interface Platform {
expect fun getPlatform(): Platform expect fun getPlatform(): Platform
expect fun openUrlInBrowser(url: String)
fun Platform.isDesktop(): Boolean { fun Platform.isDesktop(): Boolean {
return type == PlatformType.Windows || return type == PlatformType.Windows ||
type == PlatformType.Linux || type == PlatformType.Linux ||

View File

@ -53,6 +53,7 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import dev.krtirtho.spotube.modules.plugin.PluginDiscoverViewModel
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
@ -108,6 +109,7 @@ val sharedModules = module {
// Plugin system // Plugin system
singleOf(::PluginManager) { bind<PluginProvider>() } singleOf(::PluginManager) { bind<PluginProvider>() }
viewModelOf(::PluginDiscoverViewModel)
// Settings // Settings
singleOf(::SettingsRepository) singleOf(::SettingsRepository)

View File

@ -0,0 +1,22 @@
/*
* 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.core.extras
fun String.kebabToTitleCase(): String {
return this.split("-").joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } }
}

View File

@ -0,0 +1,107 @@
/*
* 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
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.client.request.parameter
import io.ktor.http.append
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class GitHubRepoSearchResponse(
@SerialName("total_count") val totalCount: Int,
@SerialName("incomplete_results") val incompleteResults: Boolean,
val items: List<GitHubRepo>,
)
@Serializable
data class GitHubRepo(
val id: Long,
@SerialName("full_name") val fullName: String,
val description: String? = null,
@SerialName("stargazers_count") val stargazersCount: Int = 0,
@SerialName("html_url") val htmlUrl: String,
val owner: GitHubOwner,
)
@Serializable
data class GitHubOwner(
val login: String,
@SerialName("avatar_url") val avatarUrl: String,
)
@Serializable
data class GitHubRelease(
@SerialName("tag_name") val tagName: String,
val assets: List<GitHubAsset>,
@SerialName("html_url") val htmlUrl: String,
)
@Serializable
data class GitHubAsset(
val name: String,
@SerialName("browser_download_url") val browserDownloadUrl: String,
)
class GitHubPluginRepository {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse {
return httpClient.get("https://api.github.com/search/repositories") {
headers {
append("Accept", "application/vnd.github+json")
append("X-GitHub-Api-Version", "2022-11-28")
}
parameter("q", "topic:spotube-zipline-plugin")
parameter("sort", "stars")
parameter("order", "desc")
parameter("page", page)
parameter("per_page", perPage)
}.body()
}
suspend fun getLatestReleaseSmplugUrl(owner: String, repo: String): String? {
return try {
val release: GitHubRelease =
httpClient.get("https://api.github.com/repos/$owner/$repo/releases/latest") {
headers {
append("Accept", "application/vnd.github+json")
append("X-GitHub-Api-Version", "2022-11-28")
}
}.body()
release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
} catch (_: Exception) {
null
}
}
fun close() {
httpClient.close()
}
}

View File

@ -0,0 +1,162 @@
/*
* 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
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.di.injectLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
data class PluginDiscoverState(
val repos: List<GitHubRepo> = emptyList(),
val currentPage: Int = 1,
val hasMore: Boolean = true,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val error: String? = null,
val installingRepoId: Long? = null,
val isInitialLoaded: Boolean = false,
)
class PluginDiscoverViewModel(
private val pluginManager: PluginManager,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<PluginDiscoverViewModel>()
private val gitHubRepo = GitHubPluginRepository()
private val _allRepos = mutableListOf<GitHubRepo>()
private val _paginationInfo = PaginationInfo()
private val _state = MutableStateFlow(PluginDiscoverState())
val state: StateFlow<PluginDiscoverState> = _state.asStateFlow()
private class PaginationInfo(
var currentPage: Int = 1,
var hasMore: Boolean = true,
var totalCount: Int = 0,
)
init {
viewModelScope.launch {
pluginManager.state.collect { pluginState ->
if (pluginState is PluginManagerStates.Data) {
val installedAuthors = pluginState.plugins.map { it.author }.toSet()
_state.update {
it.copy(repos = _allRepos.filter { repo -> repo.owner.login !in installedAuthors })
}
}
}
}
loadFirstPage()
}
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 }
}
private fun loadFirstPage() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
runCatching {
gitHubRepo.searchSpotubePlugins(page = 1)
}.onSuccess { response ->
_allRepos.clear()
_allRepos.addAll(response.items)
_paginationInfo.currentPage = 1
_paginationInfo.totalCount = response.totalCount
_paginationInfo.hasMore = _allRepos.size < response.totalCount
_state.update {
it.copy(
repos = filterInstalled(response.items),
currentPage = 1,
hasMore = _paginationInfo.hasMore,
isLoading = false,
isInitialLoaded = true,
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load plugins" }
_state.update {
it.copy(
isLoading = false,
error = e.message,
isInitialLoaded = true,
)
}
}
}
}
fun loadNextPage() {
val current = _state.value
if (current.isLoadingMore || !current.hasMore) return
viewModelScope.launch {
val nextPage = _paginationInfo.currentPage + 1
_state.update { it.copy(isLoadingMore = true, error = null) }
runCatching {
gitHubRepo.searchSpotubePlugins(page = nextPage)
}.onSuccess { response ->
_allRepos.addAll(response.items)
_paginationInfo.currentPage = nextPage
_paginationInfo.hasMore = _allRepos.size < response.totalCount
_state.update {
it.copy(
repos = filterInstalled(_allRepos),
currentPage = nextPage,
hasMore = _paginationInfo.hasMore,
isLoadingMore = false,
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more plugins" }
_state.update { it.copy(isLoadingMore = false, error = e.message) }
}
}
}
fun installPlugin(repo: GitHubRepo) {
if (_state.value.installingRepoId != null) return
_state.update { it.copy(installingRepoId = repo.id, error = null) }
viewModelScope.launch {
runCatching {
val parts = repo.fullName.split("/")
val url = gitHubRepo.getLatestReleaseSmplugUrl(parts[0], parts[1])
?: throw IllegalStateException("No .smplug asset found in latest release")
pluginManager.addPluginFromURL(url)
}.onFailure { e ->
logger.e(e) { "Failed to install plugin" }
_state.update { it.copy(error = e.message) }
}
_state.update { it.copy(installingRepoId = null) }
}
}
override fun onCleared() {
gitHubRepo.close()
super.onCleared()
}
}

View File

@ -80,6 +80,7 @@ class PluginManager(
} }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler)
private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath()
val pluginsDirPath: Path get() = pluginsDir
private val httpClient = HttpClient() private val httpClient = HttpClient()

View File

@ -18,20 +18,28 @@
package dev.krtirtho.spotube.modules.plugin package dev.krtirtho.spotube.modules.plugin
import com.goncalossilva.murmurhash.MurmurHash3 import com.goncalossilva.murmurhash.MurmurHash3
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
enum class PluginCapability { enum class PluginCapability {
@SerialName("persistent_storage")
PERSISTENT_STORAGE, PERSISTENT_STORAGE,
@SerialName("network_requests")
NETWORK_REQUESTS, NETWORK_REQUESTS,
@SerialName("webview")
WEBVIEW WEBVIEW
} }
//Set naming strategy to snake_case for better interoperability with JavaScript plugins //Set naming strategy to snake_case for better interoperability with JavaScript plugins
@Serializable @Serializable
enum class PluginAbility { enum class PluginAbility {
@SerialName("metadata")
METADATA, METADATA,
@SerialName("audio")
AUDIO, AUDIO,
@SerialName("lyrics")
LYRICS, LYRICS,
@SerialName("scrobble")
SCROBBLE, SCROBBLE,
} }

View File

@ -51,6 +51,7 @@ 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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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 dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.PlatformType
@ -66,6 +67,7 @@ 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
@ -73,7 +75,11 @@ 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.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.IconsaxImportArrow2Bulk import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
@ -86,8 +92,24 @@ 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 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.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_download import spotube.composeapp.generated.resources.plugin_action_download
import spotube.composeapp.generated.resources.plugin_action_install_from_file import spotube.composeapp.generated.resources.plugin_action_install_from_file
@ -103,6 +125,7 @@ import spotube.composeapp.generated.resources.plugin_installed_plural
import spotube.composeapp.generated.resources.plugin_installed_singular import spotube.composeapp.generated.resources.plugin_installed_singular
import spotube.composeapp.generated.resources.plugin_screen_title import spotube.composeapp.generated.resources.plugin_screen_title
import spotube.composeapp.generated.resources.plugin_section_file_title import spotube.composeapp.generated.resources.plugin_section_file_title
import spotube.composeapp.generated.resources.plugin_section_install
import spotube.composeapp.generated.resources.plugin_section_url_title import spotube.composeapp.generated.resources.plugin_section_url_title
import spotube.composeapp.generated.resources.plugin_url_placeholder import spotube.composeapp.generated.resources.plugin_url_placeholder
import spotube.composeapp.generated.resources.settings_plugins_ability_audio import spotube.composeapp.generated.resources.settings_plugins_ability_audio
@ -116,6 +139,10 @@ import spotube.composeapp.generated.resources.settings_plugins_no_plugins
import spotube.composeapp.generated.resources.settings_plugins_no_selection import spotube.composeapp.generated.resources.settings_plugins_no_selection
import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube")
private val VERIFIED_PLUGIN_OWNERS = setOf<String>()
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PluginScreen( fun PluginScreen(
@ -134,6 +161,9 @@ fun PluginScreen(
var isLoadingUrl by remember { mutableStateOf(false) } var isLoadingUrl by remember { mutableStateOf(false) }
var showInstallSheet by remember { mutableStateOf(false) } var showInstallSheet by remember { mutableStateOf(false) }
val discoverViewModel: PluginDiscoverViewModel = koinViewModel()
val discoverState by discoverViewModel.state.collectAsStateWithLifecycle()
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)
@ -173,12 +203,18 @@ fun PluginScreen(
} }
pendingPlugin?.let { pending -> 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()
if (FileSystem.SYSTEM.exists(path)) path else null
}
PluginPermissionDialog( PluginPermissionDialog(
pluginInfo = pending.entry, pluginInfo = pending.entry,
title = pending.title, title = pending.title,
message = pending.message, message = pending.message,
confirmLabel = pending.confirmLabel, confirmLabel = pending.confirmLabel,
existingPlugin = pending.existingEntry, existingPlugin = pending.existingEntry,
logoPath = logoPath,
onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) { onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) {
{ pluginManager.confirmInstall() } { pluginManager.confirmInstall() }
} else { } else {
@ -301,7 +337,9 @@ fun PluginScreen(
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
) { ) {
val discoverListState = rememberLazyListState()
LazyColumn( LazyColumn(
state = discoverListState,
modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 12.dp, start = 12.dp,
@ -487,6 +525,12 @@ fun PluginScreen(
} }
} }
val logoPath = remember(plugin.id) {
val path =
pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath()
if (FileSystem.SYSTEM.exists(path)) path else null
}
PluginCard( PluginCard(
plugin = plugin, plugin = plugin,
isSelected = isSelected, isSelected = isSelected,
@ -494,6 +538,7 @@ fun PluginScreen(
scope.launch { pluginManager.removePlugin(plugin) } scope.launch { pluginManager.removePlugin(plugin) }
}, },
isLoggedIn = isLoggedIn, isLoggedIn = isLoggedIn,
logoPath = logoPath,
onLogin = if (requiresAuth && selectedService != null) { onLogin = if (requiresAuth && selectedService != null) {
{ {
pluginManager.launchTask { pluginManager.launchTask {
@ -520,6 +565,257 @@ fun PluginScreen(
} }
} }
} }
// ── Discover plugins ─────────────────────────
if (discoverState.isLoading || discoverState.repos.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Iconsax.IconsaxGlobe,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"Discover Plugins",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
}
}
items(
discoverState.repos,
key = { it.id }
) { repo ->
val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS
val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS
val isInstalling = discoverState.installingRepoId == repo.id
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
val platformContext = LocalPlatformContext.current
AsyncImage(
model = ImageRequest.Builder(platformContext)
.data(repo.owner.avatarUrl)
.crossfade(true)
.build(),
contentDescription = repo.owner.login,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
repo.fullName.split("/")
.last()
.replace("spotube-plugin-", "")
.kebabToTitleCase(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (isOfficial) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.primary.copy(
alpha = 0.15f
)
) {
Text(
"Official",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
horizontal = 5.dp,
vertical = 1.dp
)
)
}
} else if (isVerified) {
Surface(
shape = RoundedCornerShape(4.dp),
color = Color(0xFF4CAF50).copy(alpha = 0.15f)
) {
Row(
modifier = Modifier.padding(
horizontal = 5.dp,
vertical = 1.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
2.dp
)
) {
Icon(
Iconsax.IconsaxCheckCircle,
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = Color(0xFF4CAF50)
)
Text(
"Verified",
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF4CAF50)
)
}
}
}
}
if (!repo.description.isNullOrBlank()) {
Text(
repo.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
repo.owner.login,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp)
) {
Icon(
Iconsax.IconsaxHeart,
contentDescription = "Github Stars",
modifier = Modifier.size(11.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
repo.stargazersCount.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.clickable {
openUrlInBrowser(repo.htmlUrl)
}
) {
Row(
modifier = Modifier.padding(
horizontal = 5.dp,
vertical = 2.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
3.dp
)
) {
Icon(
Iconsax.CarbonGithubLogo,
contentDescription = "Github Repository URL",
modifier = Modifier.size(10.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"github.com",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
SecondaryButton(
onClick = { discoverViewModel.installPlugin(repo) },
enabled = !isInstalling
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Iconsax.IconsaxAdd,
contentDescription = null,
)
}
Text(stringResource(Res.string.plugin_section_install))
}
}
}
}
if (discoverState.isLoadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
if (discoverState.error != null) {
item {
Text(
discoverState.error ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(
horizontal = 4.dp,
vertical = 8.dp
)
)
}
}
}
}
val density = LocalDensity.current
val shouldLoadMore = remember(density) {
derivedStateOf {
val totalItems = discoverListState.layoutInfo.totalItemsCount
val lastVisibleIndex =
discoverListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: 0
totalItems > 0 && lastVisibleIndex >= totalItems - 3
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value) {
discoverViewModel.loadNextPage()
}
} }
} }
} }

View File

@ -23,6 +23,7 @@ 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
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -38,6 +39,11 @@ import androidx.compose.ui.draw.clip
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 coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import okio.Path
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS
@ -70,6 +76,7 @@ internal fun PluginCard(
isLoggedIn: Boolean, isLoggedIn: Boolean,
onLogin: (() -> Unit)? = null, onLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null, onLogout: (() -> Unit)? = null,
logoPath: Path? = null,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -85,13 +92,25 @@ internal fun PluginCard(
.clip(RoundedCornerShape(10.dp)), .clip(RoundedCornerShape(10.dp)),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) { ) {
Box(contentAlignment = Alignment.Center) { if (logoPath != null) {
Icon( val platformContext = LocalPlatformContext.current
Iconsax.IconsaxBox, AsyncImage(
contentDescription = null, model = ImageRequest.Builder(platformContext)
tint = MaterialTheme.colorScheme.primary, .data(logoPath.toString())
modifier = Modifier.size(22.dp) .crossfade(true)
.build(),
contentDescription = plugin.name,
modifier = Modifier.fillMaxSize()
) )
} else {
Box(contentAlignment = Alignment.Center) {
Icon(
Iconsax.IconsaxBox,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp)
)
}
} }
} }

View File

@ -22,6 +22,7 @@ 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
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -38,6 +39,11 @@ import androidx.compose.ui.draw.clip
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 coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import okio.Path
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
@ -76,6 +82,7 @@ fun PluginPermissionDialog(
message: String, message: String,
confirmLabel: String?, confirmLabel: String?,
existingPlugin: PluginEntry? = null, existingPlugin: PluginEntry? = null,
logoPath: Path? = null,
onConfirm: (() -> Unit)? = null, onConfirm: (() -> Unit)? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@ -93,12 +100,24 @@ fun PluginPermissionDialog(
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( if (logoPath != null) {
imageVector = Iconsax.IconsaxBoxAdd, val platformContext = LocalPlatformContext.current
contentDescription = null, AsyncImage(
tint = MaterialTheme.colorScheme.primary, model = ImageRequest.Builder(platformContext)
modifier = Modifier.size(24.dp) .data(logoPath.toString())
) .crossfade(true)
.build(),
contentDescription = pluginInfo.name,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
imageVector = Iconsax.IconsaxBoxAdd,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(

View File

@ -0,0 +1,57 @@
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.path
import androidx.compose.ui.unit.dp
val Iconsax.CarbonGithubLogo: ImageVector
get() {
if (_CarbonGithubLogo != null) {
return _CarbonGithubLogo!!
}
_CarbonGithubLogo = ImageVector.Builder(
name = "CarbonGithubLogo",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 32f,
viewportHeight = 32f
).apply {
path(
fill = SolidColor(Color.Black),
stroke = SolidColor(Color.Black),
strokeLineWidth = 1f
) {
moveTo(16f, 2f)
arcToRelative(14f, 14f, 0f, isMoreThanHalf = false, isPositiveArc = false, -4.43f, 27.28f)
curveToRelative(0.7f, 0.13f, 1f, -0.3f, 1f, -0.67f)
reflectiveCurveToRelative(0f, -1.21f, 0f, -2.38f)
curveToRelative(-3.89f, 0.84f, -4.71f, -1.88f, -4.71f, -1.88f)
arcTo(3.71f, 3.71f, 0f, isMoreThanHalf = false, isPositiveArc = false, 6.24f, 22.3f)
curveToRelative(-1.27f, -0.86f, 0.1f, -0.85f, 0.1f, -0.85f)
arcTo(2.94f, 2.94f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.48f, 22.9f)
arcToRelative(3f, 3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 4.08f, 1.16f)
arcToRelative(2.93f, 2.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.88f, -1.87f)
curveToRelative(-3.1f, -0.36f, -6.37f, -1.56f, -6.37f, -6.92f)
arcToRelative(5.4f, 5.4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.44f, -3.76f)
arcToRelative(5f, 5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.14f, -3.7f)
reflectiveCurveToRelative(1.17f, -0.38f, 3.85f, 1.43f)
arcToRelative(13.3f, 13.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7f, 0f)
curveToRelative(2.67f, -1.81f, 3.84f, -1.43f, 3.84f, -1.43f)
arcToRelative(5f, 5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.14f, 3.7f)
arcToRelative(5.4f, 5.4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.44f, 3.76f)
curveToRelative(0f, 5.38f, -3.27f, 6.56f, -6.39f, 6.91f)
arcToRelative(3.33f, 3.33f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.95f, 2.59f)
curveToRelative(0f, 1.87f, 0f, 3.38f, 0f, 3.84f)
reflectiveCurveToRelative(0.25f, 0.81f, 1f, 0.67f)
arcTo(14f, 14f, 0f, isMoreThanHalf = false, isPositiveArc = false, 16f, 2f)
close()
}
}.build()
return _CarbonGithubLogo!!
}
@Suppress("ObjectPropertyName")
private var _CarbonGithubLogo: ImageVector? = null

View File

@ -17,6 +17,8 @@
package dev.krtirtho.spotube package dev.krtirtho.spotube
import platform.Foundation.NSURL
import platform.UIKit.UIApplication
import platform.UIKit.UIDevice import platform.UIKit.UIDevice
class IOSPlatform: Platform { class IOSPlatform: Platform {
@ -25,3 +27,8 @@ class IOSPlatform: Platform {
} }
actual fun getPlatform(): Platform = IOSPlatform() actual fun getPlatform(): Platform = IOSPlatform()
actual fun openUrlInBrowser(url: String) {
val nsUrl = NSURL.URLWithString(url) ?: return
UIApplication.sharedApplication.openURL(nsUrl)
}

View File

@ -17,6 +17,9 @@
package dev.krtirtho.spotube package dev.krtirtho.spotube
import java.awt.Desktop
import java.net.URI
class JVMPlatform : Platform { class JVMPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}" override val name: String = "Java ${System.getProperty("java.version")}"
override val type: PlatformType = System.getProperty("os.name").let { osName -> override val type: PlatformType = System.getProperty("os.name").let { osName ->
@ -34,3 +37,7 @@ class JVMPlatform : Platform {
} }
actual fun getPlatform(): Platform = JVMPlatform() actual fun getPlatform(): Platform = JVMPlatform()
actual fun openUrlInBrowser(url: String) {
Desktop.getDesktop().browse(URI(url))
}