mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
Compare commits
2 Commits
11cc5e2457
...
94ec831a96
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ec831a96 | ||
|
|
7b506b49e6 |
@ -226,6 +226,7 @@ kotlin {
|
||||
implementation(libs.nucleus.nucleus.application)
|
||||
implementation(libs.nucleus.decorated.window.tao)
|
||||
implementation(libs.compose.native.tray)
|
||||
implementation(libs.nucleus.media.control)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,9 +54,9 @@ 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.plugin.PluginViewModel
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||
import dev.krtirtho.spotube.modules.search.SearchRepository
|
||||
@ -110,7 +110,7 @@ val sharedModules = module {
|
||||
|
||||
// Plugin system
|
||||
singleOf(::PluginManager) { bind<PluginProvider>() }
|
||||
viewModelOf(::PluginDiscoverViewModel)
|
||||
viewModelOf(::PluginViewModel)
|
||||
|
||||
// Settings
|
||||
singleOf(::SettingsRepository)
|
||||
|
||||
@ -93,7 +93,7 @@ val navigationModule = module {
|
||||
SettingsScreen(settingsViewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.Plugins> {
|
||||
PluginScreen(pluginManager = get())
|
||||
PluginScreen(viewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.WebView> {}
|
||||
navigation<Routes.Playlist> {
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
/*
|
||||
* 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 installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
|
||||
_state.update {
|
||||
it.copy(repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
private fun filterInstalled(repos: List<GitHubRepo>): List<GitHubRepo> {
|
||||
val pluginState = pluginManager.state.value
|
||||
if (pluginState !is PluginManagerStates.Data) return repos
|
||||
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
|
||||
return repos.filter { it.htmlUrl !in installedUrls }
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
fun installPluginFromUrl(url: String, repoId: Long) {
|
||||
if (_state.value.installingRepoId != null) return
|
||||
_state.update { it.copy(installingRepoId = repoId, error = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
pluginManager.addPluginFromURL(url)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to install plugin" }
|
||||
_state.update { it.copy(error = e.message) }
|
||||
}
|
||||
_state.update { it.copy(installingRepoId = null) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getReleases(owner: String, repo: String): List<GitHubRelease> {
|
||||
return gitHubRepo.getReleases(owner, repo)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
gitHubRepo.close()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,623 @@
|
||||
/*
|
||||
* 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 dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.PluginService
|
||||
import io.github.vinceglb.filekit.PlatformFile
|
||||
import io.github.vinceglb.filekit.readBytes
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import net.swiftzer.semver.SemVer
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.SYSTEM
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
data class PluginAuthState(
|
||||
val requiresAuth: Boolean = false,
|
||||
val isLoggedIn: Boolean = false,
|
||||
)
|
||||
|
||||
data class PluginListItem(
|
||||
val plugin: PluginEntry,
|
||||
val isSelected: Boolean,
|
||||
val authState: PluginAuthState,
|
||||
val logoPath: Path?,
|
||||
)
|
||||
|
||||
data class AbilitySelection(
|
||||
val ability: PluginAbility,
|
||||
val plugins: List<PluginEntry>,
|
||||
val selectedPlugin: PluginEntry?,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
sealed interface UrlError {
|
||||
data object Empty : UrlError
|
||||
data object InvalidScheme : UrlError
|
||||
data object DownloadFailed : UrlError
|
||||
data class Message(val message: String) : UrlError
|
||||
}
|
||||
|
||||
sealed interface PluginUiState {
|
||||
data object Loading : PluginUiState
|
||||
|
||||
data class Data(
|
||||
val plugins: List<PluginListItem>,
|
||||
val abilitySelections: List<AbilitySelection>,
|
||||
val pendingPlugin: PluginManager.PendingPlugin? = null,
|
||||
val pendingPluginLogoPath: Path? = null,
|
||||
val urlInput: String = "",
|
||||
val urlError: UrlError? = null,
|
||||
val isLoadingUrl: Boolean = false,
|
||||
val showInstallSheet: Boolean = false,
|
||||
val showPluginInfo: PluginEntry? = null,
|
||||
val pluginInfoLogoPath: Path? = null,
|
||||
val showPluginSupport: PluginEntry? = null,
|
||||
val supportText: String? = null,
|
||||
val isLoadingSupport: Boolean = false,
|
||||
val installDialogRepo: GitHubRepo? = null,
|
||||
val releases: List<GitHubRelease> = emptyList(),
|
||||
val isLoadingReleases: Boolean = false,
|
||||
val discover: PluginDiscoverState = PluginDiscoverState(),
|
||||
) : PluginUiState
|
||||
}
|
||||
|
||||
private data class PluginManagerSnapshot(
|
||||
val state: PluginManagerStates,
|
||||
val pendingPlugin: PluginManager.PendingPlugin?,
|
||||
val metadataPlugins: List<PluginEntry>,
|
||||
val audioPlugins: List<PluginEntry>,
|
||||
val lyricsPlugins: List<PluginEntry>,
|
||||
val scrobblePlugins: List<PluginEntry>,
|
||||
val activeServices: Map<PluginAbility, PluginService?>?,
|
||||
)
|
||||
|
||||
private data class PluginScreenState(
|
||||
val urlInput: String = "",
|
||||
val urlError: UrlError? = null,
|
||||
val isLoadingUrl: Boolean = false,
|
||||
val showInstallSheet: Boolean = false,
|
||||
val showPluginInfo: PluginEntry? = null,
|
||||
val pluginInfoLogoPath: Path? = null,
|
||||
val showPluginSupport: PluginEntry? = null,
|
||||
val supportText: String? = null,
|
||||
val isLoadingSupport: Boolean = false,
|
||||
val installDialogRepo: GitHubRepo? = null,
|
||||
val releases: List<GitHubRelease> = emptyList(),
|
||||
val isLoadingReleases: Boolean = false,
|
||||
val discover: PluginDiscoverState = PluginDiscoverState(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PluginViewModel(
|
||||
private val pluginManager: PluginManager,
|
||||
private val webViewController: WebViewController,
|
||||
) : ViewModel(), KoinComponent {
|
||||
|
||||
private val logger by injectLogger<PluginViewModel>()
|
||||
private val gitHubRepo = GitHubPluginRepository()
|
||||
|
||||
private val _screenState = MutableStateFlow(PluginScreenState())
|
||||
private val _allRepos = mutableListOf<GitHubRepo>()
|
||||
private val _paginationInfo = PaginationInfo()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
pluginManager.state.collect { pluginState ->
|
||||
if (pluginState is PluginManagerStates.Data) {
|
||||
val installedUrls = pluginState.plugins
|
||||
.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }
|
||||
.toSet()
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
discover = it.discover.copy(
|
||||
repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loadFirstDiscoverPage()
|
||||
}
|
||||
|
||||
private class PaginationInfo(
|
||||
var currentPage: Int = 1,
|
||||
var hasMore: Boolean = true,
|
||||
var totalCount: Int = 0,
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private val pluginManagerSnapshot: StateFlow<PluginManagerSnapshot> = combine(
|
||||
pluginManager.state,
|
||||
pluginManager.pendingPlugin,
|
||||
pluginManager.metadataPlugins,
|
||||
pluginManager.audioPlugins,
|
||||
pluginManager.lyricsPlugins,
|
||||
pluginManager.scrobblePlugins,
|
||||
pluginManager.ziplineServices,
|
||||
) { values ->
|
||||
PluginManagerSnapshot(
|
||||
state = values[0] as PluginManagerStates,
|
||||
pendingPlugin = values[1] as PluginManager.PendingPlugin?,
|
||||
metadataPlugins = values[2] as List<PluginEntry>,
|
||||
audioPlugins = values[3] as List<PluginEntry>,
|
||||
lyricsPlugins = values[4] as List<PluginEntry>,
|
||||
scrobblePlugins = values[5] as List<PluginEntry>,
|
||||
activeServices = values[6] as Map<PluginAbility, PluginService?>?,
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
PluginManagerSnapshot(
|
||||
state = PluginManagerStates.Loading,
|
||||
pendingPlugin = null,
|
||||
metadataPlugins = emptyList(),
|
||||
audioPlugins = emptyList(),
|
||||
lyricsPlugins = emptyList(),
|
||||
scrobblePlugins = emptyList(),
|
||||
activeServices = null,
|
||||
)
|
||||
)
|
||||
|
||||
private val pluginLogoPaths: StateFlow<Map<String, Path?>> = pluginManager.state
|
||||
.filterIsInstance<PluginManagerStates.Data>()
|
||||
.map { state ->
|
||||
state.plugins.associate { plugin ->
|
||||
plugin.id to getLogoPath(plugin.id)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||
|
||||
private val pluginAuthStates: StateFlow<Map<String, PluginAuthState>> = pluginManager.state
|
||||
.filterIsInstance<PluginManagerStates.Data>()
|
||||
.map { it.plugins }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { plugins ->
|
||||
if (plugins.isEmpty()) return@flatMapLatest flowOf(emptyMap())
|
||||
val entries = plugins.associate { plugin ->
|
||||
plugin.id to pluginAuthFlow(plugin)
|
||||
}
|
||||
combine(entries.values.toList()) { states ->
|
||||
entries.keys.zip(states).toMap()
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||
|
||||
val uiState: StateFlow<PluginUiState> = combine(
|
||||
pluginManagerSnapshot,
|
||||
_screenState,
|
||||
pluginAuthStates,
|
||||
pluginLogoPaths,
|
||||
) { snapshot, screen, authStates, logoPaths ->
|
||||
when (val state = snapshot.state) {
|
||||
is PluginManagerStates.Loading -> PluginUiState.Loading
|
||||
is PluginManagerStates.Data -> PluginUiState.Data(
|
||||
plugins = state.plugins.map { plugin ->
|
||||
PluginListItem(
|
||||
plugin = plugin,
|
||||
isSelected = state.selectedPlugins.containsValue(plugin),
|
||||
authState = authStates[plugin.id] ?: PluginAuthState(),
|
||||
logoPath = logoPaths[plugin.id],
|
||||
)
|
||||
},
|
||||
abilitySelections = listOf(
|
||||
AbilitySelection(
|
||||
PluginAbility.METADATA,
|
||||
snapshot.metadataPlugins,
|
||||
state.selectedPlugins[PluginAbility.METADATA]
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.AUDIO,
|
||||
snapshot.audioPlugins,
|
||||
state.selectedPlugins[PluginAbility.AUDIO]
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.LYRICS,
|
||||
snapshot.lyricsPlugins,
|
||||
state.selectedPlugins[PluginAbility.LYRICS]
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.SCROBBLE,
|
||||
snapshot.scrobblePlugins,
|
||||
state.selectedPlugins[PluginAbility.SCROBBLE]
|
||||
),
|
||||
),
|
||||
pendingPlugin = snapshot.pendingPlugin,
|
||||
pendingPluginLogoPath = snapshot.pendingPlugin?.entry?.id?.let { getLogoPath(it) },
|
||||
urlInput = screen.urlInput,
|
||||
urlError = screen.urlError,
|
||||
isLoadingUrl = screen.isLoadingUrl,
|
||||
showInstallSheet = screen.showInstallSheet,
|
||||
showPluginInfo = screen.showPluginInfo,
|
||||
pluginInfoLogoPath = screen.pluginInfoLogoPath,
|
||||
showPluginSupport = screen.showPluginSupport,
|
||||
supportText = screen.supportText,
|
||||
isLoadingSupport = screen.isLoadingSupport,
|
||||
installDialogRepo = screen.installDialogRepo,
|
||||
releases = screen.releases,
|
||||
isLoadingReleases = screen.isLoadingReleases,
|
||||
discover = screen.discover,
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PluginUiState.Loading)
|
||||
|
||||
private fun pluginAuthFlow(plugin: PluginEntry): Flow<PluginAuthState> {
|
||||
return combine(pluginManager.state, pluginManager.ziplineServices) { state, services ->
|
||||
val data = state as? PluginManagerStates.Data
|
||||
val ability = data?.selectedPlugins?.entries
|
||||
?.firstOrNull { (_, selected) -> selected.id == plugin.id }
|
||||
?.key
|
||||
ability?.let { services?.get(it) }
|
||||
}.distinctUntilChanged()
|
||||
.flatMapLatest { service ->
|
||||
if (service == null) return@flatMapLatest flowOf(PluginAuthState())
|
||||
flow {
|
||||
val requiresAuth = try {
|
||||
service.use { coreAPI.requiresAuthentication }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// Service may have been stopped/closed concurrently when the
|
||||
// plugin selection changed. Fall back to a default state and wait
|
||||
// for the next service emission.
|
||||
false
|
||||
}
|
||||
emit(PluginAuthState(requiresAuth = requiresAuth, isLoggedIn = false))
|
||||
if (requiresAuth) {
|
||||
service.loggedInFlow.collect { loggedIn ->
|
||||
emit(PluginAuthState(requiresAuth = true, isLoggedIn = loggedIn))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLogoPath(pluginId: String): Path? {
|
||||
val path = pluginManager.pluginsDirPath / pluginId.toPath() / "logo.png".toPath()
|
||||
return if (FileSystem.SYSTEM.exists(path)) path else null
|
||||
}
|
||||
|
||||
private fun findAbilityForPlugin(pluginId: String): PluginAbility? {
|
||||
val state = pluginManagerSnapshot.value.state as? PluginManagerStates.Data
|
||||
return state?.selectedPlugins?.entries
|
||||
?.firstOrNull { (_, plugin) -> plugin.id == pluginId }
|
||||
?.key
|
||||
}
|
||||
|
||||
private fun findServiceForPlugin(pluginId: String): PluginService? {
|
||||
val ability = findAbilityForPlugin(pluginId) ?: return null
|
||||
return pluginManagerSnapshot.value.activeServices?.get(ability)
|
||||
}
|
||||
|
||||
fun onUrlInputChange(input: String) {
|
||||
_screenState.update { it.copy(urlInput = input, urlError = null) }
|
||||
}
|
||||
|
||||
fun submitUrl() {
|
||||
val url = _screenState.value.urlInput.trim()
|
||||
when {
|
||||
url.isBlank() -> {
|
||||
_screenState.update { it.copy(urlError = UrlError.Empty) }
|
||||
return
|
||||
}
|
||||
|
||||
!url.startsWith("http://") && !url.startsWith("https://") -> {
|
||||
_screenState.update { it.copy(urlError = UrlError.InvalidScheme) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_screenState.update { it.copy(urlError = null, isLoadingUrl = true) }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
pluginManager.addPluginFromURL(url)
|
||||
}.onSuccess {
|
||||
_screenState.update { it.copy(urlInput = "", isLoadingUrl = false) }
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to add plugin from URL" }
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
urlError = e.message?.let { message -> UrlError.Message(message) }
|
||||
?: UrlError.DownloadFailed,
|
||||
isLoadingUrl = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onFileSelected(file: PlatformFile?) {
|
||||
if (file == null) return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
pluginManager.preparePlugin(file.readBytes())
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to prepare plugin from file" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showInstallSheet() {
|
||||
_screenState.update { it.copy(showInstallSheet = true) }
|
||||
}
|
||||
|
||||
fun dismissInstallSheet() {
|
||||
_screenState.update { it.copy(showInstallSheet = false) }
|
||||
}
|
||||
|
||||
fun showPluginInfo(plugin: PluginEntry) {
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
showPluginInfo = plugin,
|
||||
pluginInfoLogoPath = getLogoPath(plugin.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPluginInfo() {
|
||||
_screenState.update { it.copy(showPluginInfo = null, pluginInfoLogoPath = null) }
|
||||
}
|
||||
|
||||
fun loadSupport(plugin: PluginEntry) {
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
showPluginSupport = plugin,
|
||||
isLoadingSupport = true,
|
||||
supportText = null,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val service = findServiceForPlugin(plugin.id)
|
||||
if (service == null) {
|
||||
_screenState.update { it.copy(isLoadingSupport = false) }
|
||||
return@launch
|
||||
}
|
||||
runCatching {
|
||||
service.use { coreAPI.supportMarkdownText(SemVer.parse(plugin.version)) }
|
||||
}.onSuccess { text ->
|
||||
_screenState.update { it.copy(supportText = text, isLoadingSupport = false) }
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load support text" }
|
||||
_screenState.update { it.copy(isLoadingSupport = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSupport() {
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
showPluginSupport = null,
|
||||
supportText = null,
|
||||
isLoadingSupport = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showInstallDialog(repo: GitHubRepo) {
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
installDialogRepo = repo,
|
||||
isLoadingReleases = true,
|
||||
releases = emptyList(),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val parts = repo.fullName.split("/")
|
||||
gitHubRepo.getReleases(parts[0], parts[1])
|
||||
}.onSuccess { releases ->
|
||||
_screenState.update { it.copy(releases = releases, isLoadingReleases = false) }
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load releases" }
|
||||
_screenState.update { it.copy(isLoadingReleases = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissInstallDialog() {
|
||||
_screenState.update { it.copy(installDialogRepo = null, releases = emptyList()) }
|
||||
}
|
||||
|
||||
fun selectPlugin(ability: PluginAbility, plugin: PluginEntry?) {
|
||||
pluginManager.setSelectedPlugin(ability, plugin)
|
||||
}
|
||||
|
||||
fun removePlugin(plugin: PluginEntry) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
pluginManager.removePlugin(plugin)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to remove plugin" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login(plugin: PluginEntry) {
|
||||
val service = findServiceForPlugin(plugin.id) ?: return
|
||||
pluginManager.launchTask {
|
||||
service.use { coreAPI.login() }
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(plugin: PluginEntry) {
|
||||
val service = findServiceForPlugin(plugin.id) ?: return
|
||||
pluginManager.launchTask {
|
||||
service.use { coreAPI.logout() }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
webViewController.clearData(plugin.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmInstall() {
|
||||
pluginManager.confirmInstall()
|
||||
}
|
||||
|
||||
fun dismissInstall() {
|
||||
pluginManager.dismissInstall()
|
||||
}
|
||||
|
||||
private fun filterInstalledRepos(repos: List<GitHubRepo>): List<GitHubRepo> {
|
||||
val pluginState = pluginManager.state.value
|
||||
if (pluginState !is PluginManagerStates.Data) return repos
|
||||
val installedUrls = pluginState.plugins
|
||||
.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }
|
||||
.toSet()
|
||||
return repos.filter { it.htmlUrl !in installedUrls }
|
||||
}
|
||||
|
||||
private fun loadFirstDiscoverPage() {
|
||||
viewModelScope.launch {
|
||||
_screenState.update {
|
||||
it.copy(discover = it.discover.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
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
discover = it.discover.copy(
|
||||
repos = filterInstalledRepos(response.items),
|
||||
currentPage = 1,
|
||||
hasMore = _paginationInfo.hasMore,
|
||||
isLoading = false,
|
||||
isInitialLoaded = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load plugins" }
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
discover = it.discover.copy(
|
||||
isLoading = false,
|
||||
error = e.message,
|
||||
isInitialLoaded = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNextDiscoverPage() {
|
||||
val current = _screenState.value.discover
|
||||
if (current.isLoadingMore || !current.hasMore) return
|
||||
viewModelScope.launch {
|
||||
val nextPage = _paginationInfo.currentPage + 1
|
||||
_screenState.update {
|
||||
it.copy(discover = it.discover.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
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
discover = it.discover.copy(
|
||||
repos = filterInstalledRepos(_allRepos),
|
||||
currentPage = nextPage,
|
||||
hasMore = _paginationInfo.hasMore,
|
||||
isLoadingMore = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load more plugins" }
|
||||
_screenState.update {
|
||||
it.copy(
|
||||
discover = it.discover.copy(
|
||||
isLoadingMore = false,
|
||||
error = e.message,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installPlugin(release: GitHubRelease, repoId: Long) {
|
||||
val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
|
||||
if (smplugUrl != null) {
|
||||
installPluginFromUrl(smplugUrl, repoId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installPluginFromUrl(url: String, repoId: Long) {
|
||||
if (_screenState.value.discover.installingRepoId != null) return
|
||||
_screenState.update {
|
||||
it.copy(discover = it.discover.copy(installingRepoId = repoId, error = null))
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
pluginManager.addPluginFromURL(url)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to install plugin" }
|
||||
_screenState.update {
|
||||
it.copy(discover = it.discover.copy(error = e.message))
|
||||
}
|
||||
}
|
||||
_screenState.update {
|
||||
it.copy(discover = it.discover.copy(installingRepoId = null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
gitHubRepo.close()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
@ -19,13 +19,16 @@ package dev.krtirtho.spotube.core.di
|
||||
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.mediacontrol.SystemMediaControlService
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import dev.krtirtho.spotube.core.share.JvmShareService
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.systemtray.SystemTrayService
|
||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService
|
||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService
|
||||
import org.koin.core.module.dsl.createdAtStart
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.module.dsl.withOptions
|
||||
import org.koin.dsl.module
|
||||
|
||||
actual val platformModules = module {
|
||||
@ -34,4 +37,5 @@ actual val platformModules = module {
|
||||
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
||||
single<ShareService> { JvmShareService() }
|
||||
single { SystemTrayService(get(), get(), get()) }
|
||||
single { SystemMediaControlService(get()) } withOptions { createdAtStart() }
|
||||
}
|
||||
|
||||
@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 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.mediacontrol
|
||||
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.nucleusframework.media.control.MediaControlEvent
|
||||
import dev.nucleusframework.media.control.MediaControlService
|
||||
import dev.nucleusframework.media.control.MediaMetadata
|
||||
import dev.nucleusframework.media.control.MediaPlaybackState
|
||||
import dev.nucleusframework.media.control.MediaPlaybackStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Publishes now-playing metadata and playback state to the OS media center
|
||||
* (MPRIS on Linux, Now Playing on macOS, SMTC on Windows) and forwards media
|
||||
* key commands back into the [AudioPlayerInterface].
|
||||
*
|
||||
* Events from the OS are delivered on the Swing EDT; playback commands are
|
||||
* dispatched onto [scope] so they reach the player's suspend API.
|
||||
*/
|
||||
class SystemMediaControlService(
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
) : KoinComponent, AutoCloseable {
|
||||
|
||||
private val logger by injectLogger<SystemMediaControlService>()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val enabled: Boolean = MediaControlService.isAvailable()
|
||||
|
||||
init {
|
||||
if (enabled) {
|
||||
MediaControlService.configure()
|
||||
MediaControlService.attach(::onMediaControlEvent)
|
||||
observePlayer()
|
||||
} else {
|
||||
logger.i { "System media control backend unavailable; skipping integration" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMediaControlEvent(event: MediaControlEvent) {
|
||||
when (event) {
|
||||
MediaControlEvent.Play -> scope.launch { audioPlayer.play() }
|
||||
MediaControlEvent.Pause -> scope.launch { audioPlayer.pause() }
|
||||
MediaControlEvent.Toggle -> scope.launch {
|
||||
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
|
||||
audioPlayer.pause()
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
MediaControlEvent.Next -> scope.launch { audioPlayer.skipToNext() }
|
||||
MediaControlEvent.Previous -> scope.launch { audioPlayer.skipToPrevious() }
|
||||
MediaControlEvent.Stop -> scope.launch { audioPlayer.stop() }
|
||||
is MediaControlEvent.SeekBy -> scope.launch {
|
||||
audioPlayer.seekTo(audioPlayer.positionFlow.value + event.offsetMs.milliseconds)
|
||||
}
|
||||
is MediaControlEvent.SetPosition -> scope.launch {
|
||||
audioPlayer.seekTo(event.positionMs.milliseconds)
|
||||
}
|
||||
is MediaControlEvent.SetVolume -> scope.launch {
|
||||
audioPlayer.setVolume(event.volume.toFloat())
|
||||
}
|
||||
is MediaControlEvent.OpenUri,
|
||||
MediaControlEvent.Raise,
|
||||
MediaControlEvent.Quit,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun observePlayer() {
|
||||
scope.launch {
|
||||
audioPlayer.currentMediaItemFlow.collect { item ->
|
||||
if (item != null) {
|
||||
MediaControlService.setMetadata(item.toMediaMetadata())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.positionFlow,
|
||||
) { state, position ->
|
||||
MediaPlaybackState(
|
||||
status = state.toMediaPlaybackStatus(),
|
||||
positionMs = position.inWholeMilliseconds,
|
||||
)
|
||||
}.collect { MediaControlService.setPlaybackState(it) }
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
audioPlayer.volumeFlow.collect { volume ->
|
||||
MediaControlService.setVolume(volume.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaItem.toMediaMetadata(): MediaMetadata {
|
||||
return MediaMetadata(
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = album,
|
||||
coverUrl = coverURL.ifBlank { null },
|
||||
duration = duration.inWholeMilliseconds,
|
||||
)
|
||||
}
|
||||
|
||||
private fun PlayerState.toMediaPlaybackStatus(): MediaPlaybackStatus {
|
||||
return when (this) {
|
||||
PlayerState.PLAYING -> MediaPlaybackStatus.PLAYING
|
||||
PlayerState.PAUSED -> MediaPlaybackStatus.PAUSED
|
||||
else -> MediaPlaybackStatus.STOPPED
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (enabled) {
|
||||
MediaControlService.detach()
|
||||
}
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@ -47,7 +47,7 @@ materialKolor = "4.1.1"
|
||||
murmurhash = "0.4.2"
|
||||
newpipeextractor = "v0.26.2"
|
||||
newpipeExtractorKmp = "1.3.0"
|
||||
nucleusNucleusApplication = "2.4.4"
|
||||
nucleusNucleusApplication = "2.4.6"
|
||||
semver = "2.1.0"
|
||||
vlcj = "4.12.1"
|
||||
vlcjNative = "4.12.0"
|
||||
@ -123,6 +123,7 @@ newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", v
|
||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" }
|
||||
nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" }
|
||||
nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" }
|
||||
nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" }
|
||||
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
|
||||
vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user