mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
feat(plugin_discovery): implement GitHub plugin discovery and add logo support in UI
This commit is contained in:
parent
6c9c762b87
commit
ec672d5f84
@ -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)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 ||
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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() } }
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user