mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
Compare commits
16 Commits
6d06c69078
...
7c792efdca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c792efdca | ||
|
|
94ec831a96 | ||
|
|
7b506b49e6 | ||
|
|
11cc5e2457 | ||
|
|
0a76abba65 | ||
|
|
95e9d563ec | ||
|
|
e826052691 | ||
|
|
e0b9ab530d | ||
|
|
94903dcc33 | ||
|
|
d95e1aad70 | ||
|
|
19c1d0ec35 | ||
|
|
870195bdfd | ||
|
|
4ef256249e | ||
|
|
f4eac7dbd9 | ||
|
|
16e2a743a5 | ||
|
|
b6615bdbc8 |
@ -1,5 +1,16 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"compose-hot-reload": {
|
||||
"enabled": true,
|
||||
"type": "local",
|
||||
"command": [
|
||||
"./gradlew",
|
||||
"--no-daemon",
|
||||
"--quiet",
|
||||
"--console=plain",
|
||||
"hotMcpServer"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@
|
||||
## Dependencies
|
||||
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations.
|
||||
- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`).
|
||||
- **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority).
|
||||
|
||||
## Codegen and plugin packaging
|
||||
- JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`.
|
||||
@ -30,6 +31,7 @@
|
||||
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
||||
|
||||
## UI component patterns
|
||||
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
|
||||
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
|
||||
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
|
||||
- Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain.
|
||||
|
||||
@ -33,4 +33,5 @@ plugins {
|
||||
alias(libs.plugins.uniffi) apply false
|
||||
alias(libs.plugins.cargo) apply false
|
||||
alias(libs.plugins.mokkery) apply false
|
||||
alias(libs.plugins.neucleusFramework) apply false
|
||||
}
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
import gobley.gradle.GobleyHost
|
||||
import gobley.gradle.cargo.dsl.jvm
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import dev.nucleusframework.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.compose.reload.gradle.ComposeHotRun
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.io.FileInputStream
|
||||
@ -35,6 +35,7 @@ plugins {
|
||||
alias(libs.plugins.uniffi)
|
||||
alias(libs.plugins.cargo)
|
||||
alias(libs.plugins.mokkery)
|
||||
alias(libs.plugins.neucleusFramework)
|
||||
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
||||
}
|
||||
|
||||
@ -220,6 +221,12 @@ kotlin {
|
||||
|
||||
implementation(libs.appdirs)
|
||||
implementation(libs.jna)
|
||||
|
||||
implementation(libs.nucleus.core.runtime)
|
||||
implementation(libs.nucleus.nucleus.application)
|
||||
implementation(libs.nucleus.decorated.window.tao)
|
||||
implementation(libs.compose.native.tray)
|
||||
implementation(libs.nucleus.media.control)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -280,8 +287,7 @@ dependencies {
|
||||
debugImplementation(libs.compose.uiTooling)
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
nucleus.application {
|
||||
mainClass = "dev.krtirtho.spotube.MainKt"
|
||||
|
||||
jvmArgs += listOf(
|
||||
@ -298,6 +304,8 @@ compose.desktop {
|
||||
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
|
||||
licenseFile = project.file("../LICENSE")
|
||||
|
||||
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
|
||||
|
||||
targetFormats(
|
||||
TargetFormat.Dmg,
|
||||
TargetFormat.Deb,
|
||||
@ -309,8 +317,12 @@ compose.desktop {
|
||||
|
||||
modules("jdk.unsupported")
|
||||
|
||||
homepage = "https://spotube.cc"
|
||||
|
||||
linux {
|
||||
modules("jdk.security.auth")
|
||||
debMaintainer = "Team Spotube <team.spotube@proton.me>"
|
||||
rpmLicenseType = "AGPL-3.0-or-later"
|
||||
}
|
||||
windows {
|
||||
shortcut = true
|
||||
@ -330,11 +342,13 @@ compose.desktop {
|
||||
configurationFiles.from(project.file("proguard-rules.pro"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue with compose plugin
|
||||
// https://github.com/JetBrains/compose-hot-reload/blob/master/docs/Known_limitations.md#property-composeapplicationresourcesdir-is-null-when-running-hot-reload-tasks
|
||||
tasks.withType<ComposeHotRun>().configureEach {
|
||||
jvmArgs(
|
||||
"--enable-native-access=ALL-UNNAMED"
|
||||
)
|
||||
systemProperty(
|
||||
"compose.application.resources.dir",
|
||||
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()
|
||||
|
||||
@ -20,7 +20,7 @@ package dev.krtirtho.spotube.core.webview
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
||||
val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return
|
||||
|
||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube
|
||||
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -41,6 +42,7 @@ import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import dev.krtirtho.spotube.modules.shell.AppShell
|
||||
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHome
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken
|
||||
@ -89,7 +91,11 @@ val tabs = listOf(
|
||||
)
|
||||
)
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class, ExperimentalSharedTransitionApi::class)
|
||||
@OptIn(
|
||||
KoinExperimentalAPI::class,
|
||||
ExperimentalCoroutinesApi::class,
|
||||
ExperimentalSharedTransitionApi::class
|
||||
)
|
||||
@Composable
|
||||
fun App(
|
||||
content: @Composable () -> Unit = {}
|
||||
@ -108,6 +114,10 @@ fun App(
|
||||
SpotubeTheme(settings = userSettings) {
|
||||
val baseUITheme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
@ -123,6 +133,12 @@ fun App(
|
||||
}
|
||||
}
|
||||
}
|
||||
when (currentRoute) {
|
||||
Routes.WebView -> WebViewScreen(koinInject())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ -259,12 +259,16 @@ class DeviceAudioPlayerQueue(
|
||||
return
|
||||
}
|
||||
val queue = queueFlow.value
|
||||
|
||||
// Using separate current index entry as the current track's source url has changed so it won't match
|
||||
val index = queue.indexOfFirst { entry ->
|
||||
when {
|
||||
entry is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack ->
|
||||
when (entry) {
|
||||
is QueueEntry.StreamingTrack if current is QueueEntry.StreamingTrack ->
|
||||
entry.track.id == current.track.id
|
||||
entry is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack ->
|
||||
|
||||
is QueueEntry.LocalTrack if current is QueueEntry.LocalTrack ->
|
||||
entry.url == current.url && entry.name == current.name
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
||||
import dev.krtirtho.spotube.core.navigation.navigationModule
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
||||
import dev.krtirtho.spotube.core.server.CacheManager
|
||||
import dev.krtirtho.spotube.core.server.LocalServer
|
||||
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
||||
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
||||
@ -53,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
|
||||
@ -109,7 +110,7 @@ val sharedModules = module {
|
||||
|
||||
// Plugin system
|
||||
singleOf(::PluginManager) { bind<PluginProvider>() }
|
||||
viewModelOf(::PluginDiscoverViewModel)
|
||||
viewModelOf(::PluginViewModel)
|
||||
|
||||
// Settings
|
||||
singleOf(::SettingsRepository)
|
||||
@ -130,6 +131,8 @@ val sharedModules = module {
|
||||
playbackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -141,6 +144,9 @@ val sharedModules = module {
|
||||
playbackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
libraryRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -154,6 +160,8 @@ val sharedModules = module {
|
||||
savedTracksRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -172,6 +180,8 @@ val sharedModules = module {
|
||||
libraryRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -190,6 +200,7 @@ val sharedModules = module {
|
||||
}
|
||||
singleOf(::MatchedTracksRepository)
|
||||
singleOf(::StreamingUrlRepository)
|
||||
singleOf(::CacheManager)
|
||||
singleOf(::AlternativeTracksRepository)
|
||||
singleOf(::LocalServer) withOptions {
|
||||
createdAtStart()
|
||||
|
||||
@ -18,9 +18,8 @@
|
||||
package dev.krtirtho.spotube.core.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
||||
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
|
||||
import dev.krtirtho.spotube.modules.home.HomeScreen
|
||||
import dev.krtirtho.spotube.modules.library.LibraryScreen
|
||||
@ -31,7 +30,6 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
||||
import dev.krtirtho.spotube.modules.search.SearchScreen
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
||||
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
@ -95,11 +93,9 @@ val navigationModule = module {
|
||||
SettingsScreen(settingsViewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.Plugins> {
|
||||
PluginScreen(pluginManager = get())
|
||||
}
|
||||
navigation<Routes.WebView> {
|
||||
WebViewScreen(get())
|
||||
PluginScreen(viewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.WebView> {}
|
||||
navigation<Routes.Playlist> {
|
||||
PlaylistScreen(
|
||||
playlistId = it.playlistId,
|
||||
@ -109,9 +105,6 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
shareService = get(),
|
||||
downloadsViewModel = get(),
|
||||
libraryRepository = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
}
|
||||
@ -123,9 +116,6 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
shareService = get(),
|
||||
downloadsViewModel = get(),
|
||||
libraryRepository = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
}
|
||||
@ -138,9 +128,6 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
shareService = get(),
|
||||
downloadsViewModel = get(),
|
||||
libraryRepository = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
}
|
||||
@ -155,9 +142,6 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
shareService = get(),
|
||||
downloadsViewModel = get(),
|
||||
libraryRepository = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ class AlternativeTracksRepository(
|
||||
private val matchedTracksRepository: MatchedTracksRepository,
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<AlternativeTracksRepository>()
|
||||
@ -78,6 +79,7 @@ class AlternativeTracksRepository(
|
||||
matchedTracksRepository.saveTrackSource(track, basic)
|
||||
streamingUrlRepository.invalidateCachedStreamUrl(trackId)
|
||||
streamingUrlRepository.invalidateCachedAlternatives(trackId)
|
||||
cacheManager.invalidateCacheEntry(trackId)
|
||||
audioPlayerQueue.reloadCurrent()
|
||||
logger.d { "Alternative source selection complete for track $trackId" }
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.server
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.Headers
|
||||
import io.ktor.http.HeadersBuilder
|
||||
@ -40,12 +41,12 @@ import okio.buffer
|
||||
import okio.use
|
||||
|
||||
@Serializable
|
||||
internal data class CacheIndex(
|
||||
data class CacheIndex(
|
||||
val entries: List<CacheEntry> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class CacheEntry(
|
||||
data class CacheEntry(
|
||||
val trackId: String,
|
||||
val filename: String,
|
||||
val sizeBytes: Long,
|
||||
@ -53,18 +54,26 @@ internal data class CacheEntry(
|
||||
val contentType: String = "application/octet-stream",
|
||||
)
|
||||
|
||||
internal class CacheManager(
|
||||
class CacheManager(
|
||||
private val paths: Paths,
|
||||
private val resolveCacheFolder: () -> String?,
|
||||
private val resolveSizeLimitMB: () -> Long,
|
||||
private val fileSystem: FileSystem = FileSystem.SYSTEM,
|
||||
private val cacheMutex: Mutex = Mutex(),
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
private val fileSystem: FileSystem = FileSystem.SYSTEM
|
||||
private val cacheMutex: Mutex = Mutex()
|
||||
private val json: Json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
companion object {
|
||||
private val cacheIndexFileName = "cache_index.json".toPath()
|
||||
}
|
||||
|
||||
private fun resolveCacheFolder(): String? {
|
||||
return settingsRepository.userSettings.value.cacheFolder
|
||||
}
|
||||
|
||||
private fun resolveSizeLimitMB(): Long {
|
||||
return settingsRepository.userSettings.value.cacheSizeLimitMB
|
||||
}
|
||||
|
||||
fun resolveCacheDir(): Path {
|
||||
val folder = resolveCacheFolder()
|
||||
return if (!folder.isNullOrBlank()) {
|
||||
@ -101,6 +110,18 @@ internal class CacheManager(
|
||||
return if (fileSystem.exists(filePath)) filePath to entry else null
|
||||
}
|
||||
|
||||
suspend fun invalidateCacheEntry(trackId: String) {
|
||||
cacheMutex.withLock {
|
||||
val index = readCacheIndex()
|
||||
val entry = index.entries.firstOrNull { it.trackId == trackId } ?: return
|
||||
val filePath = resolveCacheDir() / entry.filename.toPath()
|
||||
if (fileSystem.exists(filePath)) {
|
||||
fileSystem.delete(filePath)
|
||||
}
|
||||
writeCacheIndex(index.copy(entries = index.entries.filter { it.trackId != trackId }))
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveCacheFilename(track: MetadataTrack, contentType: String?): String {
|
||||
val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart()
|
||||
val title = track.title.sanitizeFilenamePart()
|
||||
|
||||
@ -19,7 +19,6 @@ package dev.krtirtho.spotube.core.server
|
||||
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.http.HttpMethod
|
||||
@ -52,10 +51,10 @@ import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
class LocalServer(
|
||||
private val paths: Paths,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
) : KoinComponent {
|
||||
|
||||
val logger by injectLogger<LocalServer>()
|
||||
@ -76,14 +75,6 @@ class LocalServer(
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val cachedCacheEnabled = MutableStateFlow(false)
|
||||
private val cachedCacheFolder = MutableStateFlow<String?>(null)
|
||||
private val cachedCacheSizeLimitMB = MutableStateFlow(0L)
|
||||
|
||||
private val cacheManager = CacheManager(
|
||||
paths = paths,
|
||||
resolveCacheFolder = { cachedCacheFolder.value },
|
||||
resolveSizeLimitMB = { cachedCacheSizeLimitMB.value },
|
||||
)
|
||||
|
||||
private val streamProxy by lazy {
|
||||
StreamProxy(
|
||||
@ -94,7 +85,6 @@ class LocalServer(
|
||||
isCachingEnabled = { cachedCacheEnabled.value },
|
||||
activePort = { activePort.value },
|
||||
scope = scope,
|
||||
logger = logger,
|
||||
)
|
||||
}
|
||||
|
||||
@ -118,8 +108,6 @@ class LocalServer(
|
||||
.collect { settings ->
|
||||
if (settings != null) {
|
||||
cachedCacheEnabled.value = settings.enableMusicCaching
|
||||
cachedCacheFolder.value = settings.cacheFolder
|
||||
cachedCacheSizeLimitMB.value = settings.cacheSizeLimitMB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
@ -51,6 +52,7 @@ import okio.Path.Companion.toPath
|
||||
import okio.SYSTEM
|
||||
import okio.buffer
|
||||
import okio.use
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
internal class StreamProxy(
|
||||
private val httpClient: HttpClient,
|
||||
@ -60,8 +62,9 @@ internal class StreamProxy(
|
||||
private val isCachingEnabled: () -> Boolean,
|
||||
private val activePort: () -> Int?,
|
||||
private val scope: CoroutineScope,
|
||||
private val logger: co.touchlab.kermit.Logger,
|
||||
) {
|
||||
): KoinComponent {
|
||||
val logger by injectLogger<StreamProxy>()
|
||||
|
||||
companion object {
|
||||
private const val HOST = "127.0.0.1"
|
||||
}
|
||||
|
||||
@ -74,6 +74,23 @@ data class TrackOptionsState(
|
||||
val isBlacklisted: Boolean = false,
|
||||
)
|
||||
|
||||
data class TrackOptionsContext(
|
||||
val currentTrackId: String? = null,
|
||||
val queueTrackIds: Set<String> = emptySet(),
|
||||
val savedTrackIds: Set<String> = emptySet(),
|
||||
val blacklistedTrackIds: Set<String> = emptySet(),
|
||||
val blacklistedArtistIds: Set<String> = emptySet(),
|
||||
val forceFavorite: Boolean = false,
|
||||
) {
|
||||
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
|
||||
isInQueue = track.id in queueTrackIds,
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = forceFavorite || track.id in savedTrackIds,
|
||||
isBlacklisted = track.id in blacklistedTrackIds ||
|
||||
track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrackOptions(
|
||||
track: MetadataTrack,
|
||||
|
||||
@ -17,9 +17,9 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebContent
|
||||
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||
import io.github.kdroidfilter.webview.cookie.CookieManager
|
||||
import dev.nucleusframework.webview.web.WebContent
|
||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||
import dev.nucleusframework.webview.cookie.CookieManager
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
|
||||
expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -51,13 +52,14 @@ import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
|
||||
import io.github.kdroidfilter.webview.jsbridge.JsMessage
|
||||
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
|
||||
import io.github.kdroidfilter.webview.web.WebView
|
||||
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||
import dev.nucleusframework.webview.web.WebView
|
||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
@ -149,6 +151,8 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@ -199,14 +203,11 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
webViewController.closeWebview()
|
||||
}) {
|
||||
Icon(FeatherIcons.X, contentDescription = "Close WebView")
|
||||
}
|
||||
}
|
||||
}) { innerPadding ->
|
||||
)
|
||||
})
|
||||
{ innerPadding ->
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
@ -216,7 +217,6 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
|
||||
},
|
||||
factory = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -40,12 +40,21 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
|
||||
private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic {
|
||||
var confidence = 0f
|
||||
if (track.title.lowercase() in video.title.lowercase()) confidence += 0.3f
|
||||
if (track.artists.any { it.name.lowercase() in video.title.lowercase() }) confidence += 0.3f
|
||||
if ((track.album != null && track.album!!.title.lowercase() in video.title.lowercase()) || video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase()
|
||||
?: "")
|
||||
) confidence += 0.2f
|
||||
if (video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000)) confidence += 0.2f // Duration within ±30 seconds
|
||||
|
||||
val isTitleMatch = track.title.lowercase() in video.title.lowercase()
|
||||
val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() }
|
||||
val isAlbumInTitle =
|
||||
track.album != null && track.album!!.title.lowercase() in video.title.lowercase()
|
||||
val isUploaderMatch =
|
||||
video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "")
|
||||
val isDurationMatch =
|
||||
video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds
|
||||
|
||||
if (isTitleMatch) confidence += 0.3f
|
||||
if (isUploaderMatch) confidence += 0.3f
|
||||
if (isDurationMatch) confidence += 0.2f // Duration within ±30 seconds
|
||||
if (isArtistInTitle) confidence += 0.1f
|
||||
if (isAlbumInTitle) confidence += 0.1f
|
||||
|
||||
return AudioSource.Basic(
|
||||
id = video.id,
|
||||
@ -66,19 +75,14 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
|
||||
override val supportedQualities: List<AudioFormat> = listOf(
|
||||
AudioFormat(
|
||||
codec = "opus",
|
||||
container = "webm",
|
||||
qualities = listOf(
|
||||
codec = "opus", container = "webm", qualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000),
|
||||
)
|
||||
),
|
||||
AudioFormat(
|
||||
codec = "aac",
|
||||
container = "mp4",
|
||||
qualities = listOf(
|
||||
), AudioFormat(
|
||||
codec = "aac", container = "mp4", qualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
@ -89,9 +93,9 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
|
||||
override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
|
||||
val isYouTubeUrl =
|
||||
track.externalUri != null &&
|
||||
(track.externalUri!!.contains("youtube.com") ||
|
||||
track.externalUri!!.contains("youtu.be"))
|
||||
track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains(
|
||||
"youtu.be"
|
||||
))
|
||||
val isYouTubeID = track.id.matches(youtubeIDRegex)
|
||||
|
||||
|
||||
@ -132,16 +136,14 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
val searchQuery = "${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim()
|
||||
|
||||
logger.i { "Searching for video with query: $searchQuery" }
|
||||
val videos =
|
||||
newPipeService.searchVideos(isrcCode ?: searchQuery)
|
||||
val videos = newPipeService.searchVideos(isrcCode ?: searchQuery)
|
||||
|
||||
logger.i { "Found ${videos.size} videos with query: $searchQuery" }
|
||||
var sources = videos.map { transformVideo(track, it) }
|
||||
|
||||
if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) {
|
||||
logger.i { "ISRC search failed, fallback to title search" }
|
||||
val videos =
|
||||
newPipeService.searchVideos(searchQuery)
|
||||
val videos = newPipeService.searchVideos(searchQuery)
|
||||
sources = videos.map { transformVideo(track, it) }
|
||||
}
|
||||
|
||||
|
||||
@ -18,33 +18,15 @@
|
||||
package dev.krtirtho.spotube.modules.album
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
@ -52,59 +34,15 @@ fun AlbumScreen(
|
||||
viewModel: AlbumViewModel,
|
||||
audioPlayerQueue: AudioPlayerQueue,
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
shareService: ShareService,
|
||||
downloadsViewModel: DownloadsViewModel,
|
||||
libraryRepository: LibraryRepository,
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
currentUserId = libraryRepository.currentUser()?.id
|
||||
}
|
||||
|
||||
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
||||
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
val queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet()
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
viewModel.handleTrackOptionsAction(track, action)
|
||||
if (action is TrackOptionsAction.Share) {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
if (action is TrackOptionsAction.Download) {
|
||||
downloadsViewModel.downloadTrack(track)
|
||||
}
|
||||
if (action is TrackOptionsAction.AddToPlaylist) {
|
||||
tracksToAddToPlaylist = listOf(track)
|
||||
showAddToPlaylistPicker = true
|
||||
}
|
||||
}
|
||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||
|
||||
val dataState = state as? AlbumScreenState.Data
|
||||
val album = dataState?.album
|
||||
@ -142,40 +80,24 @@ fun AlbumScreen(
|
||||
tracks = dataState?.tracks ?: emptyList(),
|
||||
hasMore = dataState?.nextPagination != null,
|
||||
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
|
||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
currentTrackId = trackOptionsContext.currentTrackId,
|
||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||
onTrackClick = viewModel::playAlbumFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
|
||||
trackOptionsState = trackOptionsContext::stateFor,
|
||||
onBulkDownload = viewModel::downloadTracks,
|
||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||
onBulkPlayNext = viewModel::playTracksNext,
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
trailingContent = {
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@ -26,19 +26,25 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -89,6 +95,8 @@ class AlbumViewModel(
|
||||
private val playbackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val shareService: ShareService,
|
||||
private val downloadManager: DownloadManager,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<AlbumViewModel>()
|
||||
|
||||
@ -97,12 +105,37 @@ class AlbumViewModel(
|
||||
val savedAlbumIds
|
||||
get() = libraryRepository.savedAlbumIdsFlow
|
||||
|
||||
private val _currentUserId = MutableStateFlow<String?>(null)
|
||||
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
|
||||
|
||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
||||
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
|
||||
private val _showAddToPlaylistPicker = MutableStateFlow(false)
|
||||
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
|
||||
|
||||
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
savedTracksRepository.savedTracksIdsFlow,
|
||||
_blacklistedTrackIds,
|
||||
_blacklistedArtistIds,
|
||||
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
||||
TrackOptionsContext(
|
||||
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet(),
|
||||
savedTrackIds = savedIds,
|
||||
blacklistedTrackIds = blacklistedTracks,
|
||||
blacklistedArtistIds = blacklistedArtists,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.pluginManager.selectedMetadataPlugin
|
||||
@ -111,6 +144,7 @@ class AlbumViewModel(
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
loadInitialData()
|
||||
loadCurrentUser()
|
||||
}
|
||||
}
|
||||
blacklistRepository.blacklistedTracks
|
||||
@ -121,6 +155,14 @@ class AlbumViewModel(
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun loadCurrentUser() {
|
||||
runCatching {
|
||||
_currentUserId.value = libraryRepository.currentUser()?.id
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load current user" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadInitialData() = runCatching {
|
||||
_state.value = AlbumScreenState.Loading
|
||||
val albumInfo = repository.getAlbumInfo(albumId)
|
||||
@ -199,20 +241,10 @@ class AlbumViewModel(
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val queueIndex = queue.indexOfFirst { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (queueIndex >= 0) {
|
||||
audioPlayerQueue.removeFromQueue(queue[queueIndex])
|
||||
}
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
val newQueue = audioPlayerQueue.getQueue()
|
||||
val newIndex = newQueue.indexOfFirst { e ->
|
||||
(e as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (newIndex > 0) {
|
||||
audioPlayerQueue.move(newIndex, 0)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
@ -235,12 +267,48 @@ class AlbumViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
is TrackOptionsAction.Share -> {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
is TrackOptionsAction.AddToPlaylist -> {
|
||||
_tracksToAddToPlaylist.value = listOf(track)
|
||||
_showAddToPlaylistPicker.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToPlaylist(playlistId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
_tracksToAddToPlaylist.value.map { it.id },
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to add tracks to playlist" }
|
||||
}.onSuccess {
|
||||
_showAddToPlaylistPicker.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
|
||||
_tracksToAddToPlaylist.value = tracks
|
||||
_showAddToPlaylistPicker.value = true
|
||||
}
|
||||
|
||||
fun dismissAddToPlaylistPicker() {
|
||||
_showAddToPlaylistPicker.value = false
|
||||
}
|
||||
|
||||
fun downloadTracks(tracks: List<MetadataTrack>) {
|
||||
tracks.forEach { track -> downloadManager.enqueue(track) }
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
|
||||
@ -45,10 +45,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -63,14 +59,12 @@ import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||
@ -80,13 +74,9 @@ import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackList
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
@ -95,7 +85,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@ -103,58 +92,16 @@ fun ArtistScreen(
|
||||
viewModel: ArtistViewModel,
|
||||
audioPlayerQueue: AudioPlayerQueue,
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
shareService: ShareService,
|
||||
downloadsViewModel: DownloadsViewModel,
|
||||
libraryRepository: LibraryRepository,
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
currentUserId = libraryRepository.currentUser()?.id
|
||||
}
|
||||
|
||||
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
||||
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
val queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet()
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
viewModel.handleTrackOptionsAction(track, action)
|
||||
if (action is TrackOptionsAction.Share) {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
if (action is TrackOptionsAction.Download) {
|
||||
downloadsViewModel.downloadTrack(track)
|
||||
}
|
||||
if (action is TrackOptionsAction.AddToPlaylist) {
|
||||
tracksToAddToPlaylist = listOf(track)
|
||||
showAddToPlaylistPicker = true
|
||||
}
|
||||
}
|
||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = { ApplicationMainBar() }
|
||||
@ -209,8 +156,8 @@ fun ArtistScreen(
|
||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||
onTrackClick = viewModel::playTopTracksFromTrack,
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
|
||||
trackOptionsState = trackOptionsContext::stateFor,
|
||||
onArtistClick = { trackArtist ->
|
||||
navigationCommands.navigateTo(
|
||||
Routes.Artist(
|
||||
@ -228,13 +175,10 @@ fun ArtistScreen(
|
||||
onLoadNextPage = {},
|
||||
simplified = true,
|
||||
scrollable = false,
|
||||
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
|
||||
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
|
||||
onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) },
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onBulkDownload = viewModel::downloadTracks,
|
||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||
onBulkPlayNext = viewModel::playTracksNext,
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
)
|
||||
}
|
||||
|
||||
@ -292,15 +236,8 @@ fun ArtistScreen(
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,15 +27,21 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -65,6 +71,8 @@ class ArtistViewModel(
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val shareService: ShareService,
|
||||
private val downloadManager: DownloadManager,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<ArtistViewModel>()
|
||||
|
||||
@ -77,12 +85,37 @@ class ArtistViewModel(
|
||||
val savedTrackIds
|
||||
get() = savedTracksRepository.savedTracksIdsFlow
|
||||
|
||||
private val _currentUserId = MutableStateFlow<String?>(null)
|
||||
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
|
||||
|
||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
||||
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
|
||||
private val _showAddToPlaylistPicker = MutableStateFlow(false)
|
||||
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
|
||||
|
||||
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
savedTracksRepository.savedTracksIdsFlow,
|
||||
_blacklistedTrackIds,
|
||||
_blacklistedArtistIds,
|
||||
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
||||
TrackOptionsContext(
|
||||
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet(),
|
||||
savedTrackIds = savedIds,
|
||||
blacklistedTrackIds = blacklistedTracks,
|
||||
blacklistedArtistIds = blacklistedArtists,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
libraryRepository.savedArtistIdsFlow.collect {
|
||||
@ -98,9 +131,20 @@ class ArtistViewModel(
|
||||
blacklistRepository.blacklistedArtists
|
||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
loadCurrentUser()
|
||||
loadOverview()
|
||||
}
|
||||
|
||||
private fun loadCurrentUser() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
_currentUserId.value = libraryRepository.currentUser()?.id
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load current user" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
repository.invalidateCaches()
|
||||
loadOverview()
|
||||
@ -285,22 +329,10 @@ class ArtistViewModel(
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val queueIndex = queue.indexOfFirst { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (queueIndex >= 0) {
|
||||
val entry = queue[queueIndex]
|
||||
audioPlayerQueue.removeFromQueue(entry)
|
||||
}
|
||||
val entry = QueueEntry.StreamingTrack(track = track, url = "")
|
||||
audioPlayerQueue.addToQueue(entry)
|
||||
val newQueue = audioPlayerQueue.getQueue()
|
||||
val newIndex = newQueue.indexOfFirst { e ->
|
||||
(e as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (newIndex >= 0) {
|
||||
audioPlayerQueue.move(newIndex, 0)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
@ -319,12 +351,48 @@ class ArtistViewModel(
|
||||
savedTracksRepository.saveTracks(listOf(track.id))
|
||||
}
|
||||
}
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
is TrackOptionsAction.Share -> {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
is TrackOptionsAction.AddToPlaylist -> {
|
||||
_tracksToAddToPlaylist.value = listOf(track)
|
||||
_showAddToPlaylistPicker.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToPlaylist(playlistId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
_tracksToAddToPlaylist.value.map { it.id },
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to add tracks to playlist" }
|
||||
}.onSuccess {
|
||||
_showAddToPlaylistPicker.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
|
||||
_tracksToAddToPlaylist.value = tracks
|
||||
_showAddToPlaylistPicker.value = true
|
||||
}
|
||||
|
||||
fun dismissAddToPlaylistPicker() {
|
||||
_showAddToPlaylistPicker.value = false
|
||||
}
|
||||
|
||||
fun downloadTracks(tracks: List<MetadataTrack>) {
|
||||
tracks.forEach { track -> downloadManager.enqueue(track) }
|
||||
}
|
||||
|
||||
private fun loadOverview() {
|
||||
|
||||
@ -27,35 +27,22 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
|
||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun PlaylistScreen(
|
||||
@ -63,40 +50,17 @@ fun PlaylistScreen(
|
||||
viewModel: PlaylistViewModel,
|
||||
audioPlayerQueue: AudioPlayerQueue,
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
shareService: ShareService,
|
||||
downloadsViewModel: DownloadsViewModel,
|
||||
libraryRepository: LibraryRepository,
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||
var showEditPlaylist by remember { mutableStateOf(false) }
|
||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||
var showAddTracksDialog by remember { mutableStateOf(false) }
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
viewModel.handleTrackOptionsAction(track, action)
|
||||
if (action is TrackOptionsAction.Share) {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
if (action is TrackOptionsAction.Download) {
|
||||
downloadsViewModel.downloadTrack(track)
|
||||
}
|
||||
if (action is TrackOptionsAction.AddToPlaylist) {
|
||||
tracksToAddToPlaylist = listOf(track)
|
||||
showAddToPlaylistPicker = true
|
||||
}
|
||||
}
|
||||
|
||||
val dataState = state as? PlaylistScreenState.Data
|
||||
val playlist = dataState?.playlist
|
||||
@ -108,23 +72,6 @@ fun PlaylistScreen(
|
||||
val isPlaying = currentCollectionEntry?.id == playlistId &&
|
||||
playerState == PlayerState.PLAYING
|
||||
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
||||
|
||||
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
||||
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
val queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet()
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
val errorMessage = (state as? PlaylistScreenState.Error)?.message
|
||||
val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
|
||||
|
||||
@ -168,27 +115,18 @@ fun PlaylistScreen(
|
||||
tracks = dataState?.tracks ?: emptyList(),
|
||||
hasMore = dataState?.nextPagination != null,
|
||||
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
|
||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
currentTrackId = trackOptionsContext.currentTrackId,
|
||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||
onTrackClick = viewModel::playPlaylistFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
|
||||
trackOptionsState = trackOptionsContext::stateFor,
|
||||
onBulkDownload = viewModel::downloadTracks,
|
||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||
onBulkPlayNext = viewModel::playTracksNext,
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
footerContent = footerContent,
|
||||
trailingContent = {
|
||||
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
||||
@ -219,15 +157,8 @@ fun PlaylistScreen(
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { selectedPlaylistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
selectedPlaylistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||
)
|
||||
|
||||
AddTracksToPlaylistDialog(
|
||||
|
||||
@ -26,20 +26,26 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -90,6 +96,8 @@ class PlaylistViewModel(
|
||||
private val playbackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val shareService: ShareService,
|
||||
private val downloadManager: DownloadManager,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<PlaylistViewModel>()
|
||||
|
||||
@ -107,6 +115,28 @@ class PlaylistViewModel(
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
|
||||
private val _showAddToPlaylistPicker = MutableStateFlow(false)
|
||||
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
|
||||
|
||||
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
savedTracksRepository.savedTracksIdsFlow,
|
||||
_blacklistedTrackIds,
|
||||
_blacklistedArtistIds,
|
||||
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
||||
TrackOptionsContext(
|
||||
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet(),
|
||||
savedTrackIds = savedIds,
|
||||
blacklistedTrackIds = blacklistedTracks,
|
||||
blacklistedArtistIds = blacklistedArtists,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.pluginManager.selectedMetadataPlugin
|
||||
@ -244,20 +274,10 @@ class PlaylistViewModel(
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val queueIndex = queue.indexOfFirst { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (queueIndex >= 0) {
|
||||
audioPlayerQueue.removeFromQueue(queue[queueIndex])
|
||||
}
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
val newQueue = audioPlayerQueue.getQueue()
|
||||
val newIndex = newQueue.indexOfFirst { e ->
|
||||
(e as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (newIndex > 0) {
|
||||
audioPlayerQueue.move(newIndex, 0)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
@ -280,12 +300,48 @@ class PlaylistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
is TrackOptionsAction.Share -> {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
is TrackOptionsAction.AddToPlaylist -> {
|
||||
_tracksToAddToPlaylist.value = listOf(track)
|
||||
_showAddToPlaylistPicker.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToPlaylist(playlistId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
_tracksToAddToPlaylist.value.map { it.id },
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to add tracks to playlist" }
|
||||
}.onSuccess {
|
||||
_showAddToPlaylistPicker.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
|
||||
_tracksToAddToPlaylist.value = tracks
|
||||
_showAddToPlaylistPicker.value = true
|
||||
}
|
||||
|
||||
fun dismissAddToPlaylistPicker() {
|
||||
_showAddToPlaylistPicker.value = false
|
||||
}
|
||||
|
||||
fun downloadTracks(tracks: List<MetadataTrack>) {
|
||||
tracks.forEach { track -> downloadManager.enqueue(track) }
|
||||
}
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -18,34 +18,16 @@
|
||||
package dev.krtirtho.spotube.modules.saved_tracks
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.liked_tracks
|
||||
|
||||
@ -54,56 +36,14 @@ fun SavedTracksScreen(
|
||||
viewModel: SavedTracksViewModel,
|
||||
audioPlayerQueue: AudioPlayerQueue,
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
shareService: ShareService,
|
||||
downloadsViewModel: DownloadsViewModel,
|
||||
libraryRepository: LibraryRepository,
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
currentUserId = libraryRepository.currentUser()?.id
|
||||
}
|
||||
|
||||
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
||||
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
val queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet()
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = true,
|
||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
viewModel.handleTrackOptionsAction(track, action)
|
||||
if (action is TrackOptionsAction.Share) {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
if (action is TrackOptionsAction.Download) {
|
||||
downloadsViewModel.downloadTrack(track)
|
||||
}
|
||||
if (action is TrackOptionsAction.AddToPlaylist) {
|
||||
tracksToAddToPlaylist = listOf(track)
|
||||
showAddToPlaylistPicker = true
|
||||
}
|
||||
}
|
||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||
|
||||
val dataState = state as? SavedTracksScreenState.Data
|
||||
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
|
||||
@ -133,40 +73,24 @@ fun SavedTracksScreen(
|
||||
tracks = dataState?.tracks ?: emptyList(),
|
||||
hasMore = dataState?.nextPagination != null,
|
||||
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
|
||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
currentTrackId = trackOptionsContext.currentTrackId,
|
||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||
onTrackClick = viewModel::playSavedTracksFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
|
||||
trackOptionsState = trackOptionsContext::stateFor,
|
||||
onBulkDownload = viewModel::downloadTracks,
|
||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||
onBulkPlayNext = viewModel::playTracksNext,
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
trailingContent = {
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@ -34,10 +34,15 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@ -46,6 +51,7 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.component.KoinComponent
|
||||
@ -88,6 +94,9 @@ class SavedTracksViewModel(
|
||||
private val playbackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val shareService: ShareService,
|
||||
private val downloadManager: DownloadManager,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<SavedTracksViewModel>()
|
||||
|
||||
@ -96,12 +105,36 @@ class SavedTracksViewModel(
|
||||
val savedTrackIdsFlow: StateFlow<Set<String>>
|
||||
get() = repository.savedTracksIdsFlow
|
||||
|
||||
private val _currentUserId = MutableStateFlow<String?>(null)
|
||||
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
|
||||
|
||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
||||
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
|
||||
private val _showAddToPlaylistPicker = MutableStateFlow(false)
|
||||
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
|
||||
|
||||
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
_blacklistedTrackIds,
|
||||
_blacklistedArtistIds,
|
||||
) { queue, currentEntry, blacklistedTracks, blacklistedArtists ->
|
||||
TrackOptionsContext(
|
||||
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet(),
|
||||
blacklistedTrackIds = blacklistedTracks,
|
||||
blacklistedArtistIds = blacklistedArtists,
|
||||
forceFavorite = true,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext(forceFavorite = true))
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
@ -112,6 +145,7 @@ class SavedTracksViewModel(
|
||||
repository.savedTracksIdsFlow
|
||||
) { _, _ -> }.collect {
|
||||
loadInitialData()
|
||||
loadCurrentUser()
|
||||
}
|
||||
}
|
||||
blacklistRepository.blacklistedTracks
|
||||
@ -122,6 +156,14 @@ class SavedTracksViewModel(
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun loadCurrentUser() {
|
||||
runCatching {
|
||||
_currentUserId.value = libraryRepository.currentUser()?.id
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load current user" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadInitialData() {
|
||||
runCatching {
|
||||
val tracksResult = repository.getSavedTracks()
|
||||
@ -182,20 +224,10 @@ class SavedTracksViewModel(
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val queueIndex = queue.indexOfFirst { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (queueIndex >= 0) {
|
||||
audioPlayerQueue.removeFromQueue(queue[queueIndex])
|
||||
}
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
val newQueue = audioPlayerQueue.getQueue()
|
||||
val newIndex = newQueue.indexOfFirst { e ->
|
||||
(e as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (newIndex > 0) {
|
||||
audioPlayerQueue.move(newIndex, 0)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
@ -210,12 +242,48 @@ class SavedTracksViewModel(
|
||||
}
|
||||
|
||||
is TrackOptionsAction.ToggleFavorite -> {}
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
is TrackOptionsAction.Share -> {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
is TrackOptionsAction.AddToPlaylist -> {
|
||||
_tracksToAddToPlaylist.value = listOf(track)
|
||||
_showAddToPlaylistPicker.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToPlaylist(playlistId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
_tracksToAddToPlaylist.value.map { it.id },
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to add tracks to playlist" }
|
||||
}.onSuccess {
|
||||
_showAddToPlaylistPicker.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
|
||||
_tracksToAddToPlaylist.value = tracks
|
||||
_showAddToPlaylistPicker.value = true
|
||||
}
|
||||
|
||||
fun dismissAddToPlaylistPicker() {
|
||||
_showAddToPlaylistPicker.value = false
|
||||
}
|
||||
|
||||
fun downloadTracks(tracks: List<MetadataTrack>) {
|
||||
tracks.forEach { track -> downloadManager.enqueue(track) }
|
||||
}
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
|
||||
@ -171,20 +171,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val queueIndex = queue.indexOfFirst { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
||||
}
|
||||
if (queueIndex >= 0) {
|
||||
audioPlayerQueue.removeFromQueue(queue[queueIndex])
|
||||
}
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
val newQueue = audioPlayerQueue.getQueue()
|
||||
val newIndex = newQueue.indexOfFirst { e ->
|
||||
(e as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
||||
}
|
||||
if (newIndex > 0) {
|
||||
audioPlayerQueue.move(newIndex, 0)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
|
||||
@ -20,6 +20,10 @@ package dev.krtirtho.spotube.modules.settings
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import dev.krtirtho.spotube.core.db.Database
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@ -28,14 +32,20 @@ class SettingsRepository(private val database: Database) {
|
||||
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
||||
}
|
||||
|
||||
val userSettings: Flow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
||||
val json = prefs[SETTINGS_KEY]
|
||||
if (json != null) {
|
||||
Json.decodeFromString<UserSettings>(json as String)
|
||||
} else {
|
||||
UserSettings() // Default value
|
||||
}
|
||||
}
|
||||
}.stateIn(
|
||||
scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = UserSettings() // Default value
|
||||
)
|
||||
|
||||
suspend fun updateSettings(newSettings: UserSettings) {
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
|
||||
@ -32,11 +32,7 @@ interface SettingsProvider {
|
||||
class SettingsViewModel(
|
||||
private val repository: SettingsRepository
|
||||
) : ViewModel(), SettingsProvider {
|
||||
override val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
null,
|
||||
)
|
||||
override val settingsState = repository.userSettings
|
||||
|
||||
fun updateSettings(transform: UserSettings.() -> UserSettings) {
|
||||
viewModelScope.launch {
|
||||
|
||||
@ -95,8 +95,9 @@ class AlternativeTrackContentViewModel(
|
||||
}
|
||||
|
||||
fun selectAlternative(source: AudioSource) {
|
||||
val currentTrack = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
|
||||
if (currentTrack == null) return
|
||||
val currentTrack =
|
||||
(audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
|
||||
?: return
|
||||
viewModelScope.launch {
|
||||
alternativeTracksRepository.selectAlternative(currentTrack, source)
|
||||
activeSourceIdFlow.value = source.id
|
||||
|
||||
@ -19,9 +19,6 @@ package dev.krtirtho.spotube.modules.shell.player_queue
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -29,7 +26,6 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@ -44,128 +40,54 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.rememberLogger
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.ListRowTile
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||
import dev.krtirtho.spotube.core.ui.base.highlight
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
private data class QueueItemUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val durationMs: Long,
|
||||
val isCurrent: Boolean,
|
||||
val imageUrl: String?,
|
||||
val originalIndex: Int,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PlayerQueueContent(
|
||||
viewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val logger = rememberLogger("PlayerQueueContent")
|
||||
val queueContentUiState by viewModel.queueContentUiState.collectAsState()
|
||||
val queue = queueContentUiState.queue
|
||||
val currentQueueEntry = queueContentUiState.currentQueueEntry
|
||||
val filterQuery = queueContentUiState.filterQuery
|
||||
|
||||
val normalizedFilter = remember(filterQuery) { filterQuery.trim().lowercase() }
|
||||
val currentIndex = remember(queue, currentQueueEntry) {
|
||||
val current = currentQueueEntry ?: return@remember -1
|
||||
queue.indexOfFirst { entry -> entry.matchesCurrent(current) }
|
||||
}
|
||||
|
||||
val sourceItems = remember(queue, currentIndex) {
|
||||
queue.mapIndexed { index, entry ->
|
||||
val (title, subtitle, durationMs, imageUrl) = entry.toQueueDisplayData()
|
||||
QueueItemUi(
|
||||
id = "${entry.url}@$index",
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
durationMs = durationMs,
|
||||
isCurrent = index == currentIndex,
|
||||
imageUrl = imageUrl,
|
||||
originalIndex = index,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val isFiltered = normalizedFilter.isNotBlank()
|
||||
val displayList = remember { mutableStateListOf<QueueItemUi>() }
|
||||
var moveParams by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var queueVersion by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(sourceItems, normalizedFilter) {
|
||||
queueVersion++
|
||||
displayList.clear()
|
||||
val filtered = if (isFiltered) {
|
||||
sourceItems.filter { item ->
|
||||
item.title.lowercase().contains(normalizedFilter) ||
|
||||
item.subtitle.lowercase().contains(normalizedFilter)
|
||||
}
|
||||
} else {
|
||||
sourceItems
|
||||
}
|
||||
displayList.addAll(filtered)
|
||||
}
|
||||
val state by viewModel.queueContentUiState.collectAsState()
|
||||
val displayItems = state.displayItems
|
||||
val filterQuery = state.filterQuery
|
||||
val isFiltered = state.isFiltered
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableLazyListState =
|
||||
rememberReorderableLazyListState(
|
||||
val reorderableLazyListState = rememberReorderableLazyListState(
|
||||
lazyListState,
|
||||
onMove = { from, to ->
|
||||
if (isFiltered) return@rememberReorderableLazyListState
|
||||
val item = displayList.removeAt(from.index)
|
||||
displayList.add(to.index, item)
|
||||
|
||||
logger.i { "Moved item from ${from.index} to ${to.index}" }
|
||||
|
||||
moveParams = item.originalIndex to to.index
|
||||
viewModel.onMove(from.index, to.index)
|
||||
},
|
||||
)
|
||||
|
||||
fun finalizeReorder() {
|
||||
moveParams?.let { (fromOriginal, toDisplay) ->
|
||||
logger.i { "Finalizing move from $fromOriginal to $toDisplay (version $queueVersion)" }
|
||||
viewModel.moveQueueItem(fromOriginal, toDisplay)
|
||||
}
|
||||
moveParams = null
|
||||
}
|
||||
|
||||
Surface(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@ -204,7 +126,7 @@ fun PlayerQueueContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (displayList.isEmpty()) {
|
||||
if (displayItems.isEmpty()) {
|
||||
Text(
|
||||
text = "No queue entries",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@ -217,7 +139,7 @@ fun PlayerQueueContent(
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
) {
|
||||
items(displayList, key = { item -> item.id }) { item ->
|
||||
items(displayItems, key = { it.id }) { item ->
|
||||
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
|
||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
|
||||
QueueItemRow(
|
||||
@ -225,7 +147,8 @@ fun PlayerQueueContent(
|
||||
reorderScope = if (isFiltered) null else this,
|
||||
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
|
||||
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
|
||||
onDragStopped = ::finalizeReorder,
|
||||
onDragStarted = { viewModel.onDragStart() },
|
||||
onDragStopped = { viewModel.onDragStop() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -242,6 +165,7 @@ private fun QueueItemRow(
|
||||
reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?,
|
||||
onPlayClick: () -> Unit,
|
||||
onRemoveClick: () -> Unit,
|
||||
onDragStarted: () -> Unit,
|
||||
onDragStopped: () -> Unit,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
@ -263,7 +187,12 @@ private fun QueueItemRow(
|
||||
.size(24.dp)
|
||||
.then(
|
||||
if (reorderScope != null) {
|
||||
with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) }
|
||||
with(reorderScope) {
|
||||
Modifier.draggableHandle(
|
||||
onDragStarted = { onDragStarted() },
|
||||
onDragStopped = onDragStopped,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
@ -319,7 +248,7 @@ private fun QueueItemRow(
|
||||
},
|
||||
trailing = {
|
||||
Text(
|
||||
text = item.durationMs.toDurationString(),
|
||||
text = item.durationLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@ -356,53 +285,3 @@ private fun QueueItemRow(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun QueueEntry.toQueueDisplayData(): Tuple4<String, String, Long, String?> {
|
||||
return when (this) {
|
||||
is QueueEntry.StreamingTrack -> {
|
||||
val imageUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
Tuple4(
|
||||
track.title,
|
||||
track.artists.joinToString(", ") { it.name },
|
||||
track.durationMs,
|
||||
imageUrl,
|
||||
)
|
||||
}
|
||||
|
||||
is QueueEntry.LocalTrack -> Tuple4(
|
||||
name,
|
||||
artists.joinToString(", "),
|
||||
duration,
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Tuple4<A, B, C, D>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
val third: C,
|
||||
val fourth: D,
|
||||
)
|
||||
|
||||
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
|
||||
return when {
|
||||
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
|
||||
this.track.id == current.track.id
|
||||
}
|
||||
|
||||
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
|
||||
this.url == current.url && this.name == current.name
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toDurationString(): String {
|
||||
val totalSeconds = (this / 1000).coerceAtLeast(0)
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
@ -30,10 +30,20 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class QueueItemUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val durationLabel: String,
|
||||
val isCurrent: Boolean,
|
||||
val imageUrl: String?,
|
||||
val originalIndex: Int,
|
||||
)
|
||||
|
||||
data class QueueContentUiState(
|
||||
val filterQuery: String = "",
|
||||
val queue: List<QueueEntry> = emptyList(),
|
||||
val currentQueueEntry: QueueEntry? = null,
|
||||
val displayItems: List<QueueItemUi> = emptyList(),
|
||||
val isFiltered: Boolean = false,
|
||||
)
|
||||
|
||||
class PlayerQueueContentViewModel(
|
||||
@ -41,18 +51,79 @@ class PlayerQueueContentViewModel(
|
||||
) : ViewModel() {
|
||||
private val queueVisibilityFlow = MutableStateFlow(false)
|
||||
private val queueFilterFlow = MutableStateFlow("")
|
||||
private val reorderBuffer = MutableStateFlow<List<QueueItemUi>?>(null)
|
||||
private var moveFromOriginal: Int? = null
|
||||
private var moveToDisplay: Int? = null
|
||||
|
||||
val isQueueVisible: StateFlow<Boolean> = queueVisibilityFlow.asStateFlow()
|
||||
|
||||
val queueContentUiState: StateFlow<QueueContentUiState> = combine(
|
||||
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
) { queue, currentEntry ->
|
||||
val currentIndex = if (currentEntry != null) {
|
||||
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
queue.mapIndexed { index, entry ->
|
||||
val title: String
|
||||
val subtitle: String
|
||||
val durationMs: Long
|
||||
val imageUrl: String?
|
||||
|
||||
when (entry) {
|
||||
is QueueEntry.StreamingTrack -> {
|
||||
title = entry.track.title
|
||||
subtitle = entry.track.artists.joinToString(", ") { it.name }
|
||||
durationMs = entry.track.durationMs
|
||||
imageUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
}
|
||||
|
||||
is QueueEntry.LocalTrack -> {
|
||||
title = entry.name
|
||||
subtitle = entry.artists.joinToString(", ")
|
||||
durationMs = entry.duration
|
||||
imageUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
QueueItemUi(
|
||||
id = "${entry.url}@$index",
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
durationLabel = durationMs.toDurationLabel(),
|
||||
isCurrent = index == currentIndex,
|
||||
imageUrl = imageUrl,
|
||||
originalIndex = index,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val queueContentUiState: StateFlow<QueueContentUiState> = combine(
|
||||
computedItems,
|
||||
reorderBuffer,
|
||||
queueFilterFlow,
|
||||
) { queue, currentQueueEntry, filterQuery ->
|
||||
) { items, buffer, filterQuery ->
|
||||
val normalizedFilter = filterQuery.trim().lowercase()
|
||||
val isFiltered = normalizedFilter.isNotBlank()
|
||||
val filtered = if (isFiltered) {
|
||||
items.filter { item ->
|
||||
item.title.lowercase().contains(normalizedFilter) ||
|
||||
item.subtitle.lowercase().contains(normalizedFilter)
|
||||
}
|
||||
} else {
|
||||
items
|
||||
}
|
||||
QueueContentUiState(
|
||||
filterQuery = filterQuery,
|
||||
queue = queue,
|
||||
currentQueueEntry = currentQueueEntry,
|
||||
displayItems = buffer ?: filtered,
|
||||
isFiltered = isFiltered,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@ -72,13 +143,6 @@ class PlayerQueueContentViewModel(
|
||||
queueFilterFlow.value = query
|
||||
}
|
||||
|
||||
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
||||
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.move(fromIndex, toIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun playQueueItem(index: Int) {
|
||||
if (index < 0) return
|
||||
viewModelScope.launch {
|
||||
@ -96,9 +160,67 @@ class PlayerQueueContentViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
||||
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.move(fromIndex, toIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearQueue() {
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun onDragStart() {
|
||||
if (reorderBuffer.value != null) return
|
||||
val currentItems = queueContentUiState.value.displayItems
|
||||
reorderBuffer.value = currentItems.toList()
|
||||
}
|
||||
|
||||
fun onMove(from: Int, to: Int) {
|
||||
val buffer = reorderBuffer.value ?: return
|
||||
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
|
||||
val item = buffer[from]
|
||||
val newList = buffer.toMutableList().apply {
|
||||
removeAt(from)
|
||||
add(to, item)
|
||||
}
|
||||
reorderBuffer.value = newList
|
||||
moveFromOriginal = item.originalIndex
|
||||
moveToDisplay = to
|
||||
}
|
||||
|
||||
fun onDragStop() {
|
||||
val fromOriginal = moveFromOriginal
|
||||
val toDisplay = moveToDisplay
|
||||
if (fromOriginal != null && toDisplay != null) {
|
||||
moveQueueItem(fromOriginal, toDisplay)
|
||||
}
|
||||
moveFromOriginal = null
|
||||
moveToDisplay = null
|
||||
reorderBuffer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
|
||||
return when {
|
||||
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
|
||||
this.track.id == current.track.id
|
||||
}
|
||||
|
||||
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
|
||||
this.url == current.url && this.name == current.name
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toDurationLabel(): String {
|
||||
val totalSeconds = (this / 1000).coerceAtLeast(0)
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSHTTPCookieStorage
|
||||
|
||||
@ -490,6 +490,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
if (disposed || index !in currentPlaylist.indices) return
|
||||
currentIndex = index
|
||||
_currentMediaItem.tryEmit(currentPlaylist[index])
|
||||
pendingNextIndex = index
|
||||
mediaListPlayer.controls().play(index)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
@ -33,5 +36,6 @@ actual val platformModules = module {
|
||||
single<AudioPlayerInterface> { AudioPlayer(Unit) }
|
||||
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
||||
single<ShareService> { JvmShareService() }
|
||||
single { SystemTrayService(get(), get(), get(), get()) }
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.systemtray
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||
import dev.nucleusframework.application.NucleusApplicationScope
|
||||
import dev.nucleusframework.composenativetray.tray.api.Tray
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
/**
|
||||
* Renders the system tray icon + menu via Compose Native Tray (no AWT/Swing).
|
||||
*
|
||||
* Must be composed inside `nucleusApplication { }` and kept in composition
|
||||
* while the tray should be visible (the menu is fully reactive via
|
||||
* [SystemTrayService.state]).
|
||||
*/
|
||||
@Composable
|
||||
fun NucleusApplicationScope.SystemTray(
|
||||
isWindowVisible: Boolean,
|
||||
onToggleWindowVisibility: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val trayService = koinInject<SystemTrayService>()
|
||||
val state by trayService.state.collectAsState()
|
||||
|
||||
Tray(
|
||||
icon = Iconsax.IconsaxMusic,
|
||||
tooltip = "Spotube",
|
||||
primaryAction = onToggleWindowVisibility,
|
||||
) {
|
||||
Item(label = if (isWindowVisible) "Hide Window" else "Show Window") {
|
||||
onToggleWindowVisibility()
|
||||
}
|
||||
Divider()
|
||||
|
||||
Item(label = if (state.isPlaying) "Pause" else "Play") {
|
||||
trayService.togglePlayPause()
|
||||
}
|
||||
Item(label = "Next Track") { trayService.skipToNext() }
|
||||
Item(label = "Previous Track") { trayService.skipToPrevious() }
|
||||
Divider()
|
||||
|
||||
CheckableItem(
|
||||
label = "Shuffle",
|
||||
checked = state.shuffleEnabled,
|
||||
onCheckedChange = trayService::setShuffle,
|
||||
)
|
||||
SubMenu(label = "Loop") {
|
||||
Item(label = "Loop: OFF") { trayService.setLoop(LoopState.NONE) }
|
||||
Item(label = "Loop: ONE") { trayService.setLoop(LoopState.ONE) }
|
||||
Item(label = "Loop: ALL") { trayService.setLoop(LoopState.ALL) }
|
||||
}
|
||||
Divider()
|
||||
|
||||
Item(label = "Volume: ${(state.volume * 100).toInt()}%")
|
||||
Item(label = "Volume +") { trayService.increaseVolume() }
|
||||
Item(label = "Volume -") { trayService.decreaseVolume() }
|
||||
Item(label = if (state.volume <= 0f) "Unmute" else "Mute") { trayService.toggleMute() }
|
||||
Divider()
|
||||
|
||||
if (state.currentTrackId != null) {
|
||||
Item(label = if (state.isCurrentTrackLiked) "Unlike Track" else "Like Track") {
|
||||
trayService.toggleLike()
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
|
||||
Item(label = "Exit") { onExit() }
|
||||
}
|
||||
}
|
||||
@ -20,81 +20,40 @@ package dev.krtirtho.spotube.core.systemtray
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Dimension
|
||||
import java.awt.Image
|
||||
import java.awt.SystemTray
|
||||
import java.awt.Toolkit
|
||||
import java.awt.TrayIcon
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import java.awt.image.BufferedImage
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JCheckBoxMenuItem
|
||||
import javax.swing.JDialog
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.JMenuItem
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.UIManager
|
||||
|
||||
/**
|
||||
* State holder for the system tray. Owns the audio state projected into the
|
||||
* tray menu and exposes the actions the menu items trigger. It no longer
|
||||
* touches AWT/Swing — the tray itself is rendered by the [SystemTray]
|
||||
* composable via Compose Native Tray, so it stays compatible with the
|
||||
* no-AWT Tao backend.
|
||||
*/
|
||||
class SystemTrayService(
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val settingsProvider: SettingsProvider,
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
) : KoinComponent, AutoCloseable {
|
||||
) : AutoCloseable {
|
||||
|
||||
private val logger by injectLogger<SystemTrayService>()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private var trayIcon: TrayIcon? = null
|
||||
private var menuDialog: TrayMenuDialog? = null
|
||||
|
||||
private var windowVisible: Boolean = true
|
||||
private val volumeStep = 0.05f
|
||||
|
||||
private var onToggleWindowVisibility: () -> Unit = {}
|
||||
private var onExit: () -> Unit = {}
|
||||
|
||||
private val volumeStep = 0.05f
|
||||
private var trayEnabled = false
|
||||
|
||||
@Volatile
|
||||
private var currentState: TrayState? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
settingsProvider.settingsState
|
||||
.map { it?.minimizeToTray ?: false }
|
||||
.distinctUntilChanged()
|
||||
.collect { enabled ->
|
||||
trayEnabled = enabled
|
||||
if (enabled) {
|
||||
ensureTrayCreated()
|
||||
} else {
|
||||
removeTray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val state: StateFlow<TrayState> =
|
||||
combine(
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
@ -106,335 +65,98 @@ class SystemTrayService(
|
||||
) { values ->
|
||||
TrayState(
|
||||
playerState = values[0] as PlayerState,
|
||||
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||
mediaItem = values[1] as MediaItem?,
|
||||
loopState = values[2] as LoopState,
|
||||
shuffleEnabled = values[3] as Boolean,
|
||||
volume = values[4] as Float,
|
||||
currentEntry = values[5] as QueueEntry?,
|
||||
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
|
||||
)
|
||||
}.collect { state ->
|
||||
currentState = state
|
||||
SwingUtilities.invokeLater {
|
||||
updateTooltip(state)
|
||||
menuDialog?.let { dialog ->
|
||||
val panel = dialog.contentPane.getComponent(0) as? JPanel
|
||||
if (panel != null) rebuildMenuPanel(panel, dialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial)
|
||||
|
||||
fun setCallbacks(
|
||||
onToggleWindowVisibility: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
fun setCallbacks(onToggleWindowVisibility: () -> Unit) {
|
||||
this.onToggleWindowVisibility = onToggleWindowVisibility
|
||||
this.onExit = onExit
|
||||
}
|
||||
|
||||
fun hideWindow() {
|
||||
if (windowVisible) {
|
||||
windowVisible = false
|
||||
onToggleWindowVisibility()
|
||||
fun hideWindow() = onToggleWindowVisibility()
|
||||
|
||||
fun togglePlayPause() {
|
||||
scope.launch {
|
||||
if (state.value.playerState == PlayerState.PLAYING) audioPlayer.pause() else audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
fun showWindow() {
|
||||
if (!windowVisible) {
|
||||
windowVisible = true
|
||||
onToggleWindowVisibility()
|
||||
}
|
||||
fun skipToNext() {
|
||||
scope.launch { audioPlayer.skipToNext() }
|
||||
}
|
||||
|
||||
private fun ensureTrayCreated() {
|
||||
if (trayIcon != null) return
|
||||
if (!SystemTray.isSupported()) {
|
||||
logger.w { "System tray is not supported on this platform" }
|
||||
return
|
||||
fun skipToPrevious() {
|
||||
scope.launch { audioPlayer.skipToPrevious() }
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
try {
|
||||
val trayImage = createTrayIconImage()
|
||||
val icon = TrayIcon(trayImage, "Spotube")
|
||||
icon.isImageAutoSize = true
|
||||
icon.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) {
|
||||
if (windowVisible) hideWindow() else showWindow()
|
||||
} else if (e.button == MouseEvent.BUTTON3) {
|
||||
showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
SystemTray.getSystemTray().add(icon)
|
||||
trayIcon = icon
|
||||
logger.i { "System tray created" }
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to create system tray" }
|
||||
}
|
||||
}
|
||||
fun setShuffle(enabled: Boolean) {
|
||||
scope.launch { audioPlayer.shuffle(enabled) }
|
||||
}
|
||||
|
||||
private fun removeTray() {
|
||||
menuDialog?.dispose()
|
||||
menuDialog = null
|
||||
trayIcon?.let {
|
||||
try {
|
||||
SystemTray.getSystemTray().remove(it)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to remove tray icon" }
|
||||
fun setLoop(loopState: LoopState) {
|
||||
scope.launch { audioPlayer.loop(loopState) }
|
||||
}
|
||||
|
||||
fun increaseVolume() {
|
||||
scope.launch { audioPlayer.setVolume((state.value.volume + volumeStep).coerceAtMost(1f)) }
|
||||
}
|
||||
|
||||
fun decreaseVolume() {
|
||||
scope.launch { audioPlayer.setVolume((state.value.volume - volumeStep).coerceAtLeast(0f)) }
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
scope.launch { audioPlayer.setVolume(if (state.value.volume <= 0f) 0.5f else 0f) }
|
||||
}
|
||||
|
||||
fun toggleLike() {
|
||||
scope.launch {
|
||||
val trackId = (state.value.currentEntry as? QueueEntry.StreamingTrack)?.track?.id ?: return@launch
|
||||
val isLiked = state.value.savedTrackIds.contains(trackId)
|
||||
if (isLiked) {
|
||||
savedTracksRepository.removeSavedTracks(listOf(trackId))
|
||||
} else {
|
||||
savedTracksRepository.saveTracks(listOf(trackId))
|
||||
}
|
||||
}
|
||||
trayIcon = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
removeTray()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
|
||||
val dialog = menuDialog
|
||||
if (dialog != null && dialog.isVisible) {
|
||||
dialog.dispose()
|
||||
menuDialog = null
|
||||
return
|
||||
}
|
||||
|
||||
val newDialog = TrayMenuDialog().apply {
|
||||
isUndecorated = true
|
||||
isAlwaysOnTop = true
|
||||
focusableWindowState = true
|
||||
background = Color(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
val panel = JPanel()
|
||||
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
|
||||
panel.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(Color(80, 80, 80), 1),
|
||||
BorderFactory.createEmptyBorder(4, 4, 4, 4)
|
||||
)
|
||||
panel.background = UIManager.getColor("PopupMenu.background") ?: Color(45, 45, 45)
|
||||
|
||||
newDialog.contentPane.layout = BorderLayout()
|
||||
newDialog.contentPane.add(panel, BorderLayout.CENTER)
|
||||
newDialog.addWindowFocusListener(object : WindowAdapter() {
|
||||
override fun windowLostFocus(e: WindowEvent) {
|
||||
newDialog.isVisible = false
|
||||
newDialog.dispose()
|
||||
if (menuDialog === newDialog) menuDialog = null
|
||||
}
|
||||
})
|
||||
|
||||
menuDialog = newDialog
|
||||
rebuildMenuPanel(panel, newDialog)
|
||||
newDialog.pack()
|
||||
|
||||
val screenBounds = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||
.defaultScreenDevice.defaultConfiguration.bounds
|
||||
val dialogWidth = newDialog.width
|
||||
val dialogHeight = newDialog.height
|
||||
val posX = if (x + dialogWidth > screenBounds.width) x - dialogWidth else x
|
||||
val posY = if (y + dialogHeight > screenBounds.height) y - dialogHeight else y
|
||||
newDialog.setLocation(posX, posY)
|
||||
newDialog.isVisible = true
|
||||
newDialog.requestFocus()
|
||||
}
|
||||
|
||||
private fun rebuildMenuPanel(panel: JPanel, dialog: JDialog) {
|
||||
panel.removeAll()
|
||||
val state = currentState
|
||||
if (state == null) {
|
||||
panel.add(createDisabledLabel("Loading..."))
|
||||
return
|
||||
}
|
||||
|
||||
val media = state.mediaItem
|
||||
val trackTitle = media?.title ?: "No track playing"
|
||||
val trackArtist = media?.artist ?: ""
|
||||
|
||||
panel.add(createDisabledLabel(
|
||||
if (trackArtist.isNotEmpty()) "$trackTitle - $trackArtist" else trackTitle
|
||||
))
|
||||
panel.add(createSeparator())
|
||||
|
||||
panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") {
|
||||
if (windowVisible) hideWindow() else showWindow()
|
||||
})
|
||||
panel.add(createSeparator())
|
||||
|
||||
val isPlaying = state.playerState == PlayerState.PLAYING
|
||||
panel.add(createMenuItem(if (isPlaying) "Pause" else "Play") {
|
||||
scope.launch { if (isPlaying) audioPlayer.pause() else audioPlayer.play() }
|
||||
})
|
||||
panel.add(createMenuItem("Next Track") {
|
||||
scope.launch { audioPlayer.skipToNext() }
|
||||
})
|
||||
panel.add(createMenuItem("Previous Track") {
|
||||
scope.launch { audioPlayer.skipToPrevious() }
|
||||
})
|
||||
panel.add(createSeparator())
|
||||
|
||||
panel.add(createCheckBoxMenuItem("Shuffle", state.shuffleEnabled) {
|
||||
scope.launch { audioPlayer.shuffle(!state.shuffleEnabled) }
|
||||
})
|
||||
|
||||
val loopSubMenu = JPanel()
|
||||
loopSubMenu.layout = BoxLayout(loopSubMenu, BoxLayout.Y_AXIS)
|
||||
loopSubMenu.background = panel.background
|
||||
val loopGroup = javax.swing.ButtonGroup()
|
||||
val loopNone = javax.swing.JRadioButtonMenuItem("Loop: OFF", state.loopState == LoopState.NONE)
|
||||
val loopOne = javax.swing.JRadioButtonMenuItem("Loop: ONE", state.loopState == LoopState.ONE)
|
||||
val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL)
|
||||
|
||||
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||
listOf(loopNone, loopOne, loopAll).forEach { item ->
|
||||
item.isOpaque = true
|
||||
val normalBg = item.background
|
||||
item.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground }
|
||||
override fun mouseExited(e: MouseEvent) { item.background = normalBg }
|
||||
})
|
||||
}
|
||||
|
||||
loopGroup.add(loopNone)
|
||||
loopGroup.add(loopOne)
|
||||
loopGroup.add(loopAll)
|
||||
loopNone.addActionListener { scope.launch { audioPlayer.loop(LoopState.NONE) } }
|
||||
loopOne.addActionListener { scope.launch { audioPlayer.loop(LoopState.ONE) } }
|
||||
loopAll.addActionListener { scope.launch { audioPlayer.loop(LoopState.ALL) } }
|
||||
loopSubMenu.add(loopNone)
|
||||
loopSubMenu.add(loopOne)
|
||||
loopSubMenu.add(loopAll)
|
||||
panel.add(createSubMenuLabel("Loop"))
|
||||
panel.add(loopSubMenu)
|
||||
panel.add(createSeparator())
|
||||
|
||||
val volumePercent = (state.volume * 100).toInt()
|
||||
panel.add(createDisabledLabel("Volume: $volumePercent%"))
|
||||
panel.add(createMenuItem("Volume +") {
|
||||
scope.launch {
|
||||
val newVol = (state.volume + volumeStep).coerceAtMost(1f)
|
||||
audioPlayer.setVolume(newVol)
|
||||
}
|
||||
})
|
||||
panel.add(createMenuItem("Volume -") {
|
||||
scope.launch {
|
||||
val newVol = (state.volume - volumeStep).coerceAtLeast(0f)
|
||||
audioPlayer.setVolume(newVol)
|
||||
}
|
||||
})
|
||||
val isMuted = state.volume <= 0f
|
||||
panel.add(createMenuItem(if (isMuted) "Unmute" else "Mute") {
|
||||
scope.launch { audioPlayer.setVolume(if (isMuted) 0.5f else 0f) }
|
||||
})
|
||||
panel.add(createSeparator())
|
||||
|
||||
val currentTrackId = (state.currentEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
val isLiked = currentTrackId != null && state.savedTrackIds.contains(currentTrackId)
|
||||
val likeLabel = if (currentTrackId == null) "Like (No track)" else if (isLiked) "Unlike Track" else "Like Track"
|
||||
panel.add(createMenuItem(likeLabel, enabled = currentTrackId != null) {
|
||||
val trackId = currentTrackId ?: return@createMenuItem
|
||||
scope.launch {
|
||||
if (isLiked) savedTracksRepository.removeSavedTracks(listOf(trackId))
|
||||
else savedTracksRepository.saveTracks(listOf(trackId))
|
||||
}
|
||||
})
|
||||
panel.add(createSeparator())
|
||||
|
||||
panel.add(createMenuItem("Exit") { onExit() })
|
||||
|
||||
panel.revalidate()
|
||||
panel.repaint()
|
||||
dialog.pack()
|
||||
}
|
||||
|
||||
private fun updateTooltip(state: TrayState) {
|
||||
val media = state.mediaItem
|
||||
trayIcon?.toolTip = if (media != null) {
|
||||
"Spotube - ${media.title} by ${media.artist}"
|
||||
} else {
|
||||
"Spotube"
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMenuItem(label: String, enabled: Boolean = true, action: () -> Unit): JMenuItem {
|
||||
return JMenuItem(label).apply {
|
||||
isEnabled = enabled
|
||||
isOpaque = true
|
||||
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||
addActionListener { action() }
|
||||
val normalBg = background
|
||||
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
|
||||
override fun mouseExited(e: MouseEvent) { background = normalBg }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCheckBoxMenuItem(label: String, selected: Boolean, action: () -> Unit): JCheckBoxMenuItem {
|
||||
return JCheckBoxMenuItem(label, selected).apply {
|
||||
isOpaque = true
|
||||
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||
addActionListener { action() }
|
||||
val normalBg = background
|
||||
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
|
||||
override fun mouseExited(e: MouseEvent) { background = normalBg }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDisabledLabel(text: String): JLabel {
|
||||
return JLabel(text).apply {
|
||||
foreground = Color(150, 150, 150)
|
||||
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||
alignmentX = java.awt.Component.LEFT_ALIGNMENT
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSubMenuLabel(text: String): JLabel {
|
||||
return JLabel(text).apply {
|
||||
foreground = UIManager.getColor("MenuItem.foreground") ?: Color.WHITE
|
||||
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||
alignmentX = java.awt.Component.LEFT_ALIGNMENT
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSeparator(): javax.swing.JSeparator {
|
||||
return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) }
|
||||
}
|
||||
|
||||
private fun createTrayIconImage(): Image {
|
||||
val resource = javaClass.classLoader.getResource("icon.png")
|
||||
?: javaClass.classLoader.getResource("icons/spotube.png")
|
||||
if (resource != null) {
|
||||
return Toolkit.getDefaultToolkit().getImage(resource)
|
||||
}
|
||||
val img = BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = img.createGraphics()
|
||||
g.color = Color(34, 197, 94)
|
||||
g.fillOval(0, 0, 16, 16)
|
||||
g.color = Color.WHITE
|
||||
g.fillOval(5, 5, 6, 6)
|
||||
g.dispose()
|
||||
return img
|
||||
}
|
||||
|
||||
private class TrayMenuDialog : JDialog()
|
||||
|
||||
private data class TrayState(
|
||||
data class TrayState(
|
||||
val playerState: PlayerState,
|
||||
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||
val mediaItem: MediaItem?,
|
||||
val loopState: LoopState,
|
||||
val shuffleEnabled: Boolean,
|
||||
val volume: Float,
|
||||
val currentEntry: QueueEntry?,
|
||||
val savedTrackIds: Set<String>,
|
||||
) {
|
||||
val isPlaying: Boolean get() = playerState == PlayerState.PLAYING
|
||||
|
||||
val currentTrackId: String? get() = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
|
||||
val isCurrentTrackLiked: Boolean
|
||||
get() = currentTrackId != null && savedTrackIds.contains(currentTrackId)
|
||||
|
||||
companion object {
|
||||
val Initial =
|
||||
TrayState(
|
||||
playerState = PlayerState.IDLE,
|
||||
mediaItem = null,
|
||||
loopState = LoopState.NONE,
|
||||
shuffleEnabled = false,
|
||||
volume = 1f,
|
||||
currentEntry = null,
|
||||
savedTrackIds = emptySet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.window.WindowDraggableArea
|
||||
@ -44,6 +45,10 @@ import dev.krtirtho.spotube.resources.iconsax.FluentMaximize
|
||||
import dev.krtirtho.spotube.resources.iconsax.FluentMinus
|
||||
import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.nucleusframework.window.WindowControls
|
||||
import dev.nucleusframework.window.WindowControlsRenderer
|
||||
import dev.nucleusframework.window.WindowDoubleClickAction
|
||||
import dev.nucleusframework.window.windowDragArea
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
|
||||
@ -55,9 +60,9 @@ actual fun ApplicationMainBar(
|
||||
actions: @Composable (RowScope.() -> Unit),
|
||||
backButton: Boolean,
|
||||
) {
|
||||
LocalWindowScope.current.WindowDraggableArea {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize)
|
||||
) {
|
||||
TopAppBar(
|
||||
title = title,
|
||||
@ -72,12 +77,12 @@ actual fun ApplicationMainBar(
|
||||
subtitle = subtitle,
|
||||
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
|
||||
)
|
||||
WindowButtons(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
LocalWindowScope.current.WindowControls(
|
||||
modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),
|
||||
renderer = WindowControlsRenderer.Platform, // default
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WindowButtons(modifier: Modifier = Modifier) {
|
||||
|
||||
@ -19,7 +19,8 @@ package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.window.ApplicationScope
|
||||
import dev.nucleusframework.application.NucleusApplicationScope
|
||||
|
||||
val LocalApplicationScope = staticCompositionLocalOf<ApplicationScope> {
|
||||
val LocalApplicationScope = staticCompositionLocalOf< NucleusApplicationScope> {
|
||||
error("No ApplicationScope found in LocalApplicationScope")
|
||||
}
|
||||
@ -18,11 +18,11 @@
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import dev.nucleusframework.application.NucleusDecoratedWindowScope
|
||||
|
||||
// LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy.
|
||||
val LocalWindowScope = compositionLocalOf<FrameWindowScope> {
|
||||
val LocalWindowScope = compositionLocalOf<NucleusDecoratedWindowScope> {
|
||||
error("No Window found in LocalWindowScope")
|
||||
}
|
||||
val LocalWindowState = compositionLocalOf<WindowState> {
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.context.GlobalContext
|
||||
|
||||
@ -25,18 +25,21 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowDecoration
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import com.sun.jna.Library
|
||||
import com.sun.jna.Native
|
||||
import dev.krtirtho.spotube.core.di.initKoin
|
||||
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import dev.krtirtho.spotube.core.systemtray.SystemTray
|
||||
import dev.krtirtho.spotube.core.systemtray.SystemTrayService
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalWindowState
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||
import dev.nucleusframework.application.DecoratedWindow
|
||||
import dev.nucleusframework.application.NucleusBackend
|
||||
import dev.nucleusframework.application.nucleusApplication
|
||||
import io.github.vinceglb.filekit.FileKit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -54,14 +57,39 @@ private object KoinServicesProvider : KoinComponent {
|
||||
val settingsProvider: SettingsProvider get() = get()
|
||||
}
|
||||
|
||||
private interface LibC : Library {
|
||||
fun setenv(name: String, value: String, overwrite: Int): Int
|
||||
|
||||
companion object {
|
||||
val INSTANCE: LibC = Native.load("c", LibC::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebKitGTK is only used on Linux (Windows = WebView2, macOS = WKWebView).
|
||||
* The webview is created *after* the window is already mapped and the Tao
|
||||
* render/swap loop is running. WebKitGTK's accelerated-compositing path then
|
||||
* initialises its own GL context in-process, racing Tao's swap thread on the
|
||||
* same Mesa display, which deterministically segfaults `libgallium` on the
|
||||
* next Compose flush. Disabling WebKit's hardware-accelerated compositing
|
||||
* (and its DMABUF renderer) removes that GL context entirely — login pages
|
||||
* render fine in software. Must run before libwebkit2gtk is loaded.
|
||||
*/
|
||||
private fun disableWebKitGpuCompositing() {
|
||||
if (!System.getProperty("os.name").lowercase().contains("linux")) return
|
||||
LibC.INSTANCE.setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 1)
|
||||
LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
disableWebKitGpuCompositing()
|
||||
FileKit.init(appId = "dev.krtirtho.spotube")
|
||||
initKoin()
|
||||
NewPipeDownloader.init(KoinPathsProvider.paths)
|
||||
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
application {
|
||||
nucleusApplication(backend = NucleusBackend.Tao) {
|
||||
val windowState = rememberWindowState(
|
||||
width = 1080.dp,
|
||||
height = 720.dp
|
||||
@ -75,16 +103,24 @@ fun main() {
|
||||
|
||||
var isWindowVisible by remember { mutableStateOf(true) }
|
||||
|
||||
trayService.setCallbacks(
|
||||
onToggleWindowVisibility = { isWindowVisible = !isWindowVisible },
|
||||
onExit = {
|
||||
val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }
|
||||
val onExit = {
|
||||
trayService.close()
|
||||
appScope.cancel()
|
||||
exitApplication()
|
||||
}
|
||||
)
|
||||
|
||||
Window(
|
||||
trayService.setCallbacks(onToggleWindowVisibility)
|
||||
|
||||
if (minimizeToTray) {
|
||||
SystemTray(
|
||||
isWindowVisible = isWindowVisible,
|
||||
onToggleWindowVisibility = onToggleWindowVisibility,
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
DecoratedWindow(
|
||||
state = windowState,
|
||||
onCloseRequest = {
|
||||
if (minimizeToTray) {
|
||||
@ -96,12 +132,11 @@ fun main() {
|
||||
}
|
||||
},
|
||||
title = "Spotube",
|
||||
decoration = WindowDecoration.Undecorated(),
|
||||
visible = isWindowVisible,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalApplicationScope provides this@application,
|
||||
LocalWindowScope provides this@Window,
|
||||
LocalApplicationScope provides this@nucleusApplication,
|
||||
LocalWindowScope provides this@DecoratedWindow,
|
||||
LocalWindowState provides windowState
|
||||
) {
|
||||
App()
|
||||
|
||||
@ -20,55 +20,56 @@ android-minSdk = "28"
|
||||
android-targetSdk = "36"
|
||||
androidx-activity = "1.13.0"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-core = "1.19.0"
|
||||
androidx-espresso = "3.7.0"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-lifecycle = "2.11.0"
|
||||
androidx-testExt = "1.3.0"
|
||||
appdirs = "1.5.0"
|
||||
cache4k = "0.14.0"
|
||||
carApp = "1.7.0"
|
||||
composeHotReload = "1.1.0"
|
||||
composeMultiplatform = "1.10.3"
|
||||
coil = "3.4.0"
|
||||
composeHotReload = "1.2.0"
|
||||
composeMultiplatform = "1.11.1"
|
||||
coil = "3.5.0"
|
||||
composePlaceholderMaterial3 = "1.0.12"
|
||||
composeShimmer = "1.5.0-beta02"
|
||||
composeShimmer = "1.5.0"
|
||||
desugar_jdk_libs = "2.1.5"
|
||||
haze = "2.0.0-alpha01"
|
||||
haze = "2.0.0-alpha03"
|
||||
jna = "5.18.1"
|
||||
junit = "4.13.2"
|
||||
kermit = "2.1.0"
|
||||
kmpZip = "0.10.0"
|
||||
kmpZip = "0.13.0"
|
||||
kotlin = "2.3.21"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
kotlinx-io = "0.9.0"
|
||||
kotlinx-coroutines = "1.11.0"
|
||||
kotlinx-io = "0.9.1"
|
||||
material3 = "1.10.0-alpha05"
|
||||
kotlinx-serialization-json = "1.11.0"
|
||||
materialKolor = "4.1.1"
|
||||
murmurhash = "0.4.2"
|
||||
newpipeextractor = "v0.26.2"
|
||||
newpipeExtractorKmp = "1.2.1"
|
||||
newpipeExtractorKmp = "1.3.0"
|
||||
nucleusNucleusApplication = "2.4.6"
|
||||
semver = "2.1.0"
|
||||
vlcj = "4.12.1"
|
||||
vlcjNative = "4.12.0"
|
||||
ziplineVersion = "1.27.0"
|
||||
ktor = "3.4.3"
|
||||
jetbrainsKotlinJvm = "2.3.21"
|
||||
kotlinStdlib = "2.3.21"
|
||||
ktor = "3.5.1"
|
||||
kotlinStdlib = "2.4.10"
|
||||
runner = "1.7.0"
|
||||
core = "1.7.0"
|
||||
filekit = "0.13.0"
|
||||
compose-webview = "0.1.0-SNAPSHOT"
|
||||
koin = "4.2.1"
|
||||
multiplatform-nav3-ui = "1.0.0-alpha06"
|
||||
compose-multiplatform-adaptive = "1.3.0-alpha02"
|
||||
compose-multiplatform-lifecycle = "2.10.0-alpha05"
|
||||
filekit = "0.14.2"
|
||||
compose-webview = "1.0.1"
|
||||
composeNativeTray = "2.0.3"
|
||||
koin = "4.2.2"
|
||||
multiplatform-nav3-ui = "1.1.1"
|
||||
compose-multiplatform-adaptive = "1.3.0-beta02"
|
||||
compose-multiplatform-lifecycle = "2.11.0"
|
||||
feather-icons = "1.1.1"
|
||||
material3-window-size = "1.9.0"
|
||||
kotlinx-datetime = "0.7.1"
|
||||
media3 = "1.8.0"
|
||||
kotlinx-datetime = "0.8.0"
|
||||
media3 = "1.10.1"
|
||||
androidx-datastore = "1.2.1"
|
||||
ksp = "2.3.5"
|
||||
spotube-gradle = "0.1.0"
|
||||
spotube-gradle = "0.1.1"
|
||||
cryptography = "0.6.0"
|
||||
reorderable = "3.1.0"
|
||||
vlcjBundler = "0.1.0"
|
||||
@ -120,6 +121,10 @@ material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "m
|
||||
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
||||
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
||||
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" }
|
||||
vlcj-natives = { module = "uk.co.caprica:vlcj-natives", version.ref = "vlcjNative" }
|
||||
@ -141,7 +146,8 @@ androidx-core = { group = "androidx.test", name = "core", version.ref = "core" }
|
||||
filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" }
|
||||
filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
|
||||
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" }
|
||||
compose-webview = { module = "io.github.kdroidfilter:composewebview", version.ref = "compose-webview" }
|
||||
compose-webview = { module = "dev.nucleusframework:composewebview", version.ref = "compose-webview" }
|
||||
compose-native-tray = { module = "dev.nucleusframework:composenativetray", version.ref = "composeNativeTray" }
|
||||
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
|
||||
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
|
||||
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
|
||||
@ -173,7 +179,7 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul
|
||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }
|
||||
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
|
||||
mavenPublish = { id = "maven-publish" }
|
||||
@ -184,3 +190,4 @@ uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
|
||||
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
|
||||
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
|
||||
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }
|
||||
neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" }
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@ -20,6 +20,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
google {
|
||||
mavenContent {
|
||||
includeGroupAndSubgroups("androidx")
|
||||
@ -29,13 +30,13 @@ pluginManagement {
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
mavenLocal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenLocal()
|
||||
google {
|
||||
mavenContent {
|
||||
includeGroupAndSubgroups("androidx")
|
||||
@ -45,7 +46,6 @@ dependencyResolutionManagement {
|
||||
}
|
||||
mavenCentral()
|
||||
maven("https://jitpack.io")
|
||||
mavenLocal()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user