mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 14:44:00 +00:00
Compare commits
No commits in common. "94ec831a96f780554255df5bd092754422278c46" and "11cc5e24574a92af81ea45e36bb1ad22678a9d66" have entirely different histories.
94ec831a96
...
11cc5e2457
@ -226,7 +226,6 @@ kotlin {
|
|||||||
implementation(libs.nucleus.nucleus.application)
|
implementation(libs.nucleus.nucleus.application)
|
||||||
implementation(libs.nucleus.decorated.window.tao)
|
implementation(libs.nucleus.decorated.window.tao)
|
||||||
implementation(libs.compose.native.tray)
|
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.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.plugin.PluginViewModel
|
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||||
import dev.krtirtho.spotube.modules.search.SearchRepository
|
import dev.krtirtho.spotube.modules.search.SearchRepository
|
||||||
@ -110,7 +110,7 @@ val sharedModules = module {
|
|||||||
|
|
||||||
// Plugin system
|
// Plugin system
|
||||||
singleOf(::PluginManager) { bind<PluginProvider>() }
|
singleOf(::PluginManager) { bind<PluginProvider>() }
|
||||||
viewModelOf(::PluginViewModel)
|
viewModelOf(::PluginDiscoverViewModel)
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
singleOf(::SettingsRepository)
|
singleOf(::SettingsRepository)
|
||||||
|
|||||||
@ -93,7 +93,7 @@ val navigationModule = module {
|
|||||||
SettingsScreen(settingsViewModel = koinViewModel())
|
SettingsScreen(settingsViewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
navigation<Routes.Plugins> {
|
navigation<Routes.Plugins> {
|
||||||
PluginScreen(viewModel = koinViewModel())
|
PluginScreen(pluginManager = get())
|
||||||
}
|
}
|
||||||
navigation<Routes.WebView> {}
|
navigation<Routes.WebView> {}
|
||||||
navigation<Routes.Playlist> {
|
navigation<Routes.Playlist> {
|
||||||
|
|||||||
@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* 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
@ -1,623 +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 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,16 +19,13 @@ package dev.krtirtho.spotube.core.di
|
|||||||
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
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.paths.Paths
|
||||||
import dev.krtirtho.spotube.core.share.JvmShareService
|
import dev.krtirtho.spotube.core.share.JvmShareService
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.systemtray.SystemTrayService
|
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.JvmLocalMediaDiscoveryService
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService
|
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.singleOf
|
||||||
import org.koin.core.module.dsl.withOptions
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
actual val platformModules = module {
|
actual val platformModules = module {
|
||||||
@ -37,5 +34,4 @@ actual val platformModules = module {
|
|||||||
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
||||||
single<ShareService> { JvmShareService() }
|
single<ShareService> { JvmShareService() }
|
||||||
single { SystemTrayService(get(), get(), get()) }
|
single { SystemTrayService(get(), get(), get()) }
|
||||||
single { SystemMediaControlService(get()) } withOptions { createdAtStart() }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,148 +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.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"
|
murmurhash = "0.4.2"
|
||||||
newpipeextractor = "v0.26.2"
|
newpipeextractor = "v0.26.2"
|
||||||
newpipeExtractorKmp = "1.3.0"
|
newpipeExtractorKmp = "1.3.0"
|
||||||
nucleusNucleusApplication = "2.4.6"
|
nucleusNucleusApplication = "2.4.4"
|
||||||
semver = "2.1.0"
|
semver = "2.1.0"
|
||||||
vlcj = "4.12.1"
|
vlcj = "4.12.1"
|
||||||
vlcjNative = "4.12.0"
|
vlcjNative = "4.12.0"
|
||||||
@ -123,7 +123,6 @@ newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", v
|
|||||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||||
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" }
|
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-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" }
|
nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" }
|
||||||
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
|
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
|
||||||
vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }
|
vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user