From ec672d5f8430e6777f06ab29dc6675723c0d7cc4 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 25 Jul 2026 14:57:35 +0600 Subject: [PATCH] feat(plugin_discovery): implement GitHub plugin discovery and add logo support in UI --- .../dev/krtirtho/spotube/Platform.android.kt | 14 +- .../composeResources/values/strings.xml | 1 + .../kotlin/dev/krtirtho/spotube/Platform.kt | 2 + .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../spotube/core/extras/CaseConverterExt.kt | 22 ++ .../modules/plugin/GitHubPluginRepo.kt | 107 +++++++ .../modules/plugin/PluginDiscoverViewModel.kt | 162 ++++++++++ .../spotube/modules/plugin/PluginManager.kt | 1 + .../spotube/modules/plugin/PluginModels.kt | 8 + .../spotube/modules/plugin/PluginScreen.kt | 296 ++++++++++++++++++ .../modules/plugin/components/PluginCard.kt | 31 +- .../components/PluginPermissionsDialog.kt | 31 +- .../resources/iconsax/CarbonGithubLogo.kt | 57 ++++ .../dev/krtirtho/spotube/Platform.ios.kt | 9 +- .../dev/krtirtho/spotube/Platform.jvm.kt | 9 +- 15 files changed, 737 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt index e06110d6..cded046f 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt @@ -17,11 +17,23 @@ package dev.krtirtho.spotube +import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Build +import org.koin.core.context.GlobalContext class AndroidPlatform : Platform { override val name: String = "Android ${Build.VERSION.SDK_INT}" override val type: PlatformType = PlatformType.Android } -actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file +actual fun getPlatform(): Platform = AndroidPlatform() + +actual fun openUrlInBrowser(url: String) { + val context = GlobalContext.get().get() + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 535fa90e..010d4259 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -151,6 +151,7 @@ Logout Download from URL Install from file + Install • %1$s Permissions requested diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt index 74bf0230..07948670 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt @@ -28,6 +28,8 @@ interface Platform { expect fun getPlatform(): Platform +expect fun openUrlInBrowser(url: String) + fun Platform.isDesktop(): Boolean { return type == PlatformType.Windows || type == PlatformType.Linux || diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index c244d038..bf4f1eb0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -53,6 +53,7 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel import dev.krtirtho.spotube.modules.playlist.PlaylistRepository 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.PluginProvider import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository @@ -108,6 +109,7 @@ val sharedModules = module { // Plugin system singleOf(::PluginManager) { bind() } + viewModelOf(::PluginDiscoverViewModel) // Settings singleOf(::SettingsRepository) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt new file mode 100644 index 00000000..87e08b95 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt @@ -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 . + */ + +package dev.krtirtho.spotube.core.extras + +fun String.kebabToTitleCase(): String { + return this.split("-").joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt new file mode 100644 index 00000000..285f4866 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt @@ -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 . + */ + +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, +) + +@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, + @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() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt new file mode 100644 index 00000000..1793a5df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginDiscoverViewModel.kt @@ -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 . + */ + +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 = 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() + private val gitHubRepo = GitHubPluginRepository() + + private val _allRepos = mutableListOf() + private val _paginationInfo = PaginationInfo() + + private val _state = MutableStateFlow(PluginDiscoverState()) + val state: StateFlow = _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): List { + 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() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index 84cd4110..19d701a5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -80,6 +80,7 @@ class PluginManager( } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() + val pluginsDirPath: Path get() = pluginsDir private val httpClient = HttpClient() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt index 5e2d1fdf..406ab554 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt @@ -18,20 +18,28 @@ package dev.krtirtho.spotube.modules.plugin import com.goncalossilva.murmurhash.MurmurHash3 +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable enum class PluginCapability { + @SerialName("persistent_storage") PERSISTENT_STORAGE, + @SerialName("network_requests") NETWORK_REQUESTS, + @SerialName("webview") WEBVIEW } //Set naming strategy to snake_case for better interoperability with JavaScript plugins @Serializable enum class PluginAbility { + @SerialName("metadata") METADATA, + @SerialName("audio") AUDIO, + @SerialName("lyrics") LYRICS, + @SerialName("scrobble") SCROBBLE, } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index 05aa75a2..d3d7f941 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import 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.webview.WebViewController import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.openUrlInBrowser import dev.krtirtho.spotube.modules.plugin.components.PluginCard import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog 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.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxBox +import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText +import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe +import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk @@ -86,8 +92,24 @@ import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.flow.StateFlow 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.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_action_download 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_screen_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_url_placeholder 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_plugin_content_description +private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") + +private val VERIFIED_PLUGIN_OWNERS = setOf() + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PluginScreen( @@ -134,6 +161,9 @@ fun PluginScreen( var isLoadingUrl 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 urlSchemeError = stringResource(Res.string.plugin_error_url_scheme) val downloadFailed = stringResource(Res.string.plugin_error_download_failed) @@ -173,12 +203,18 @@ fun PluginScreen( } pendingPlugin?.let { pending -> + val logoPath = remember(pending.existingEntry?.id) { + val existingId = pending.existingEntry?.id ?: return@remember null + val path = pluginManager.pluginsDirPath / existingId.toPath() / "logo.png".toPath() + if (FileSystem.SYSTEM.exists(path)) path else null + } PluginPermissionDialog( pluginInfo = pending.entry, title = pending.title, message = pending.message, confirmLabel = pending.confirmLabel, existingPlugin = pending.existingEntry, + logoPath = logoPath, onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) { { pluginManager.confirmInstall() } } else { @@ -301,7 +337,9 @@ fun PluginScreen( .fillMaxSize() .padding(innerPadding) ) { + val discoverListState = rememberLazyListState() LazyColumn( + state = discoverListState, modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), contentPadding = PaddingValues( 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( plugin = plugin, isSelected = isSelected, @@ -494,6 +538,7 @@ fun PluginScreen( scope.launch { pluginManager.removePlugin(plugin) } }, isLoggedIn = isLoggedIn, + logoPath = logoPath, onLogin = if (requiresAuth && selectedService != null) { { 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() + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt index 0ac91d6f..3a688f68 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt @@ -23,6 +23,7 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.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 okio.Path import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS @@ -70,6 +76,7 @@ internal fun PluginCard( isLoggedIn: Boolean, onLogin: (() -> Unit)? = null, onLogout: (() -> Unit)? = null, + logoPath: Path? = null, ) { Row( modifier = Modifier @@ -85,13 +92,25 @@ internal fun PluginCard( .clip(RoundedCornerShape(10.dp)), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) ) { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp) + 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, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt index 0e48fbca..b641fe89 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -22,6 +22,7 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.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 okio.Path import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton @@ -76,6 +82,7 @@ fun PluginPermissionDialog( message: String, confirmLabel: String?, existingPlugin: PluginEntry? = null, + logoPath: Path? = null, onConfirm: (() -> Unit)? = null, onDismiss: () -> Unit, ) { @@ -93,12 +100,24 @@ fun PluginPermissionDialog( .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), contentAlignment = Alignment.Center ) { - Icon( - imageVector = Iconsax.IconsaxBoxAdd, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) + if (logoPath != null) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .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)) { Text( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt new file mode 100644 index 00000000..c6273c77 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt @@ -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 diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt index a585c5bb..aa38bb5f 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt @@ -17,6 +17,8 @@ package dev.krtirtho.spotube +import platform.Foundation.NSURL +import platform.UIKit.UIApplication import platform.UIKit.UIDevice class IOSPlatform: Platform { @@ -24,4 +26,9 @@ class IOSPlatform: Platform { override val type: PlatformType = PlatformType.IOS } -actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file +actual fun getPlatform(): Platform = IOSPlatform() + +actual fun openUrlInBrowser(url: String) { + val nsUrl = NSURL.URLWithString(url) ?: return + UIApplication.sharedApplication.openURL(nsUrl) +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt index afe8388f..dba09a14 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt @@ -17,6 +17,9 @@ package dev.krtirtho.spotube +import java.awt.Desktop +import java.net.URI + class JVMPlatform : Platform { override val name: String = "Java ${System.getProperty("java.version")}" override val type: PlatformType = System.getProperty("os.name").let { osName -> @@ -33,4 +36,8 @@ class JVMPlatform : Platform { } } -actual fun getPlatform(): Platform = JVMPlatform() \ No newline at end of file +actual fun getPlatform(): Platform = JVMPlatform() + +actual fun openUrlInBrowser(url: String) { + Desktop.getDesktop().browse(URI(url)) +} \ No newline at end of file