Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
c94c0d7737
Merge d48b0a2f65 into fe0ebb47fe 2026-08-07 09:06:20 -07:00
50 changed files with 2673 additions and 3075 deletions

View File

@ -1,16 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"compose-hot-reload": {
"enabled": true,
"type": "local",
"command": [
"./gradlew",
"--no-daemon",
"--quiet",
"--console=plain",
"hotMcpServer"
]
}
}
}

View File

@ -20,7 +20,6 @@
## 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/`.
@ -31,7 +30,6 @@
- `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.

View File

@ -33,5 +33,4 @@ 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
}

View File

@ -17,7 +17,7 @@
import gobley.gradle.GobleyHost
import gobley.gradle.cargo.dsl.jvm
import dev.nucleusframework.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream
@ -35,7 +35,6 @@ plugins {
alias(libs.plugins.uniffi)
alias(libs.plugins.cargo)
alias(libs.plugins.mokkery)
alias(libs.plugins.neucleusFramework)
kotlin("plugin.atomicfu") version libs.versions.kotlin
}
@ -221,12 +220,6 @@ 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)
}
}
}
@ -287,7 +280,8 @@ dependencies {
debugImplementation(libs.compose.uiTooling)
}
nucleus.application {
compose.desktop {
application {
mainClass = "dev.krtirtho.spotube.MainKt"
jvmArgs += listOf(
@ -304,8 +298,6 @@ nucleus.application {
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
targetFormats(
TargetFormat.Dmg,
TargetFormat.Deb,
@ -317,12 +309,8 @@ nucleus.application {
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
@ -341,14 +329,12 @@ nucleus.application {
buildTypes.release.proguard {
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()

View File

@ -20,7 +20,7 @@ package dev.krtirtho.spotube.core.webview
import android.webkit.CookieManager
import android.webkit.WebStorage
import android.webkit.WebView
import dev.nucleusframework.webview.web.WebViewState
import io.github.kdroidfilter.webview.web.WebViewState
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return

View File

@ -19,7 +19,6 @@ 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
@ -42,7 +41,6 @@ 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
@ -91,11 +89,7 @@ val tabs = listOf(
)
)
@OptIn(
KoinExperimentalAPI::class,
ExperimentalCoroutinesApi::class,
ExperimentalSharedTransitionApi::class
)
@OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun App(
content: @Composable () -> Unit = {}
@ -114,10 +108,6 @@ 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(
@ -133,12 +123,6 @@ fun App(
}
}
}
when (currentRoute) {
Routes.WebView -> WebViewScreen(koinInject())
else -> {}
}
}
}
content()
}
}

View File

@ -259,16 +259,12 @@ 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 if current is QueueEntry.StreamingTrack ->
when {
entry is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack ->
entry.track.id == current.track.id
is QueueEntry.LocalTrack if current is QueueEntry.LocalTrack ->
entry is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack ->
entry.url == current.url && entry.name == current.name
else -> false
}
}

View File

@ -27,7 +27,6 @@ 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
@ -54,9 +53,9 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import dev.krtirtho.spotube.modules.plugin.PluginDiscoverViewModel
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.modules.search.SearchRepository
@ -110,7 +109,7 @@ val sharedModules = module {
// Plugin system
singleOf(::PluginManager) { bind<PluginProvider>() }
viewModelOf(::PluginViewModel)
viewModelOf(::PluginDiscoverViewModel)
// Settings
singleOf(::SettingsRepository)
@ -131,8 +130,6 @@ val sharedModules = module {
playbackHelper = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
)
}
@ -144,9 +141,6 @@ val sharedModules = module {
playbackHelper = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
libraryRepository = get(),
shareService = get(),
downloadManager = get(),
)
}
@ -160,8 +154,6 @@ val sharedModules = module {
savedTracksRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
)
}
@ -180,8 +172,6 @@ val sharedModules = module {
libraryRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
)
}
@ -200,7 +190,6 @@ val sharedModules = module {
}
singleOf(::MatchedTracksRepository)
singleOf(::StreamingUrlRepository)
singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository)
singleOf(::LocalServer) withOptions {
createdAtStart()

View File

@ -18,8 +18,9 @@
package dev.krtirtho.spotube.core.navigation
import androidx.navigation3.runtime.NavKey
import dev.krtirtho.spotube.modules.album.AlbumScreen
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.blacklist.BlacklistScreen
import dev.krtirtho.spotube.modules.home.HomeScreen
import dev.krtirtho.spotube.modules.library.LibraryScreen
@ -30,6 +31,7 @@ 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
@ -93,9 +95,11 @@ val navigationModule = module {
SettingsScreen(settingsViewModel = koinViewModel())
}
navigation<Routes.Plugins> {
PluginScreen(viewModel = koinViewModel())
PluginScreen(pluginManager = get())
}
navigation<Routes.WebView> {
WebViewScreen(get())
}
navigation<Routes.WebView> {}
navigation<Routes.Playlist> {
PlaylistScreen(
playlistId = it.playlistId,
@ -105,6 +109,9 @@ val navigationModule = module {
),
audioPlayerQueue = get(),
audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(),
)
}
@ -116,6 +123,9 @@ val navigationModule = module {
),
audioPlayerQueue = get(),
audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(),
)
}
@ -128,6 +138,9 @@ val navigationModule = module {
),
audioPlayerQueue = get(),
audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(),
)
}
@ -142,6 +155,9 @@ val navigationModule = module {
),
audioPlayerQueue = get(),
audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(),
)
}

View File

@ -29,7 +29,6 @@ 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>()
@ -79,7 +78,6 @@ 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" }
}

View File

@ -19,7 +19,6 @@ 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
@ -41,12 +40,12 @@ import okio.buffer
import okio.use
@Serializable
data class CacheIndex(
internal data class CacheIndex(
val entries: List<CacheEntry> = emptyList()
)
@Serializable
data class CacheEntry(
internal data class CacheEntry(
val trackId: String,
val filename: String,
val sizeBytes: Long,
@ -54,26 +53,18 @@ data class CacheEntry(
val contentType: String = "application/octet-stream",
)
class CacheManager(
internal class CacheManager(
private val paths: Paths,
private val settingsRepository: SettingsRepository,
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 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()) {
@ -110,18 +101,6 @@ 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()

View File

@ -19,6 +19,7 @@ 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
@ -51,10 +52,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>()
@ -75,6 +76,14 @@ 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(
@ -85,6 +94,7 @@ class LocalServer(
isCachingEnabled = { cachedCacheEnabled.value },
activePort = { activePort.value },
scope = scope,
logger = logger,
)
}
@ -108,6 +118,8 @@ class LocalServer(
.collect { settings ->
if (settings != null) {
cachedCacheEnabled.value = settings.enableMusicCaching
cachedCacheFolder.value = settings.cacheFolder
cachedCacheSizeLimitMB.value = settings.cacheSizeLimitMB
}
}
}

View File

@ -21,7 +21,6 @@ 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
@ -52,7 +51,6 @@ 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,
@ -62,9 +60,8 @@ internal class StreamProxy(
private val isCachingEnabled: () -> Boolean,
private val activePort: () -> Int?,
private val scope: CoroutineScope,
): KoinComponent {
val logger by injectLogger<StreamProxy>()
private val logger: co.touchlab.kermit.Logger,
) {
companion object {
private const val HOST = "127.0.0.1"
}

View File

@ -74,23 +74,6 @@ 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,

View File

@ -17,9 +17,9 @@
package dev.krtirtho.spotube.core.webview
import dev.nucleusframework.webview.web.WebContent
import dev.nucleusframework.webview.web.WebViewNavigator
import dev.nucleusframework.webview.cookie.CookieManager
import io.github.kdroidfilter.webview.web.WebContent
import io.github.kdroidfilter.webview.web.WebViewNavigator
import io.github.kdroidfilter.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

View File

@ -17,7 +17,7 @@
package dev.krtirtho.spotube.core.webview
import dev.nucleusframework.webview.web.WebViewState
import io.github.kdroidfilter.webview.web.WebViewState
expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)

View File

@ -19,7 +19,6 @@ 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
@ -52,14 +51,13 @@ 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 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
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
class PostMessageHandler(
private val onMessageReceived: (String) -> Unit = {}
@ -151,8 +149,6 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
topBar = {
ApplicationMainBar(
title = {
Row(
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@ -203,11 +199,14 @@ 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(),
@ -217,6 +216,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
navigator.evaluateJavaScript(bridgeBootstrapScript)
},
factory = null
)
}
}

View File

@ -40,21 +40,12 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic {
var confidence = 0f
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
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
return AudioSource.Basic(
id = video.id,
@ -75,14 +66,19 @@ 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),
@ -93,9 +89,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)
@ -133,17 +129,19 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
logger.i { "No YouTube ID or URL found, performing search with track metadata" }
val isrcCode = track.isrcCode ?: isrcProvider.auto(track)
val searchQuery = "${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim()
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) }
}

View File

@ -18,15 +18,33 @@
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(
@ -34,15 +52,59 @@ 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 currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.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 dataState = state as? AlbumScreenState.Data
val album = dataState?.album
@ -80,24 +142,40 @@ fun AlbumScreen(
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = trackOptionsContext.currentTrackId,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
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 = viewModel::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
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
},
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
},
)

View File

@ -26,25 +26,19 @@ 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
@ -95,8 +89,6 @@ 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>()
@ -105,37 +97,12 @@ 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
@ -144,7 +111,6 @@ class AlbumViewModel(
.distinctUntilChanged()
.collect {
loadInitialData()
loadCurrentUser()
}
}
blacklistRepository.blacklistedTracks
@ -155,14 +121,6 @@ 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)
@ -241,10 +199,20 @@ class AlbumViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
@ -267,48 +235,12 @@ class AlbumViewModel(
}
}
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
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>) {

View File

@ -45,6 +45,10 @@ 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
@ -59,12 +63,14 @@ 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
@ -74,9 +80,13 @@ 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
@ -85,6 +95,7 @@ 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
@ -92,16 +103,58 @@ 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()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.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
}
}
Scaffold(
topBar = { ApplicationMainBar() }
@ -156,8 +209,8 @@ fun ArtistScreen(
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playTopTracksFromTrack,
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor,
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onArtistClick = { trackArtist ->
navigationCommands.navigateTo(
Routes.Artist(
@ -175,10 +228,13 @@ fun ArtistScreen(
onLoadNextPage = {},
simplified = true,
scrollable = false,
onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) },
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
@ -236,8 +292,15 @@ fun ArtistScreen(
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
}
}

View File

@ -27,21 +27,15 @@ 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
@ -71,8 +65,6 @@ 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>()
@ -85,37 +77,12 @@ 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 {
@ -131,20 +98,9 @@ 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()
@ -329,10 +285,22 @@ class ArtistViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
@ -351,48 +319,12 @@ class ArtistViewModel(
savedTracksRepository.saveTracks(listOf(track.id))
}
}
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
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() {

View File

@ -27,22 +27,35 @@ 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(
@ -50,17 +63,40 @@ 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
@ -72,6 +108,23 @@ 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())
@ -115,18 +168,27 @@ fun PlaylistScreen(
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = trackOptionsContext.currentTrackId,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
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 = viewModel::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
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
},
footerContent = footerContent,
trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
@ -157,8 +219,15 @@ fun PlaylistScreen(
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { selectedPlaylistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
selectedPlaylistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
AddTracksToPlaylistDialog(

View File

@ -26,26 +26,20 @@ 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
@ -96,8 +90,6 @@ 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>()
@ -115,28 +107,6 @@ 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
@ -274,10 +244,20 @@ class PlaylistViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
@ -300,48 +280,12 @@ class PlaylistViewModel(
}
}
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
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 {

View File

@ -0,0 +1,180 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.di.injectLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
data class PluginDiscoverState(
val repos: List<GitHubRepo> = emptyList(),
val currentPage: Int = 1,
val hasMore: Boolean = true,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val error: String? = null,
val installingRepoId: Long? = null,
val isInitialLoaded: Boolean = false,
)
class PluginDiscoverViewModel(
private val pluginManager: PluginManager,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<PluginDiscoverViewModel>()
private val gitHubRepo = GitHubPluginRepository()
private val _allRepos = mutableListOf<GitHubRepo>()
private val _paginationInfo = PaginationInfo()
private val _state = MutableStateFlow(PluginDiscoverState())
val state: StateFlow<PluginDiscoverState> = _state.asStateFlow()
private class PaginationInfo(
var currentPage: Int = 1,
var hasMore: Boolean = true,
var totalCount: Int = 0,
)
init {
viewModelScope.launch {
pluginManager.state.collect { pluginState ->
if (pluginState is PluginManagerStates.Data) {
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
_state.update {
it.copy(repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls })
}
}
}
}
loadFirstPage()
}
private fun filterInstalled(repos: List<GitHubRepo>): List<GitHubRepo> {
val pluginState = pluginManager.state.value
if (pluginState !is PluginManagerStates.Data) return repos
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
return repos.filter { it.htmlUrl !in installedUrls }
}
private fun loadFirstPage() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
runCatching {
gitHubRepo.searchSpotubePlugins(page = 1)
}.onSuccess { response ->
_allRepos.clear()
_allRepos.addAll(response.items)
_paginationInfo.currentPage = 1
_paginationInfo.totalCount = response.totalCount
_paginationInfo.hasMore = _allRepos.size < response.totalCount
_state.update {
it.copy(
repos = filterInstalled(response.items),
currentPage = 1,
hasMore = _paginationInfo.hasMore,
isLoading = false,
isInitialLoaded = true,
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load plugins" }
_state.update {
it.copy(
isLoading = false,
error = e.message,
isInitialLoaded = true,
)
}
}
}
}
fun loadNextPage() {
val current = _state.value
if (current.isLoadingMore || !current.hasMore) return
viewModelScope.launch {
val nextPage = _paginationInfo.currentPage + 1
_state.update { it.copy(isLoadingMore = true, error = null) }
runCatching {
gitHubRepo.searchSpotubePlugins(page = nextPage)
}.onSuccess { response ->
_allRepos.addAll(response.items)
_paginationInfo.currentPage = nextPage
_paginationInfo.hasMore = _allRepos.size < response.totalCount
_state.update {
it.copy(
repos = filterInstalled(_allRepos),
currentPage = nextPage,
hasMore = _paginationInfo.hasMore,
isLoadingMore = false,
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more plugins" }
_state.update { it.copy(isLoadingMore = false, error = e.message) }
}
}
}
fun installPlugin(repo: GitHubRepo) {
if (_state.value.installingRepoId != null) return
_state.update { it.copy(installingRepoId = repo.id, error = null) }
viewModelScope.launch {
runCatching {
val parts = repo.fullName.split("/")
val url = gitHubRepo.getLatestReleaseSmplugUrl(parts[0], parts[1])
?: throw IllegalStateException("No .smplug asset found in latest release")
pluginManager.addPluginFromURL(url)
}.onFailure { e ->
logger.e(e) { "Failed to install plugin" }
_state.update { it.copy(error = e.message) }
}
_state.update { it.copy(installingRepoId = null) }
}
}
fun installPluginFromUrl(url: String, repoId: Long) {
if (_state.value.installingRepoId != null) return
_state.update { it.copy(installingRepoId = repoId, error = null) }
viewModelScope.launch {
runCatching {
pluginManager.addPluginFromURL(url)
}.onFailure { e ->
logger.e(e) { "Failed to install plugin" }
_state.update { it.copy(error = e.message) }
}
_state.update { it.copy(installingRepoId = null) }
}
}
suspend fun getReleases(owner: String, repo: String): List<GitHubRelease> {
return gitHubRepo.getReleases(owner, repo)
}
override fun onCleared() {
gitHubRepo.close()
super.onCleared()
}
}

View File

@ -1,623 +0,0 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.core.zipline.PluginService
import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.swiftzer.semver.SemVer
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
import okio.SYSTEM
import org.koin.core.component.KoinComponent
data class PluginAuthState(
val requiresAuth: Boolean = false,
val isLoggedIn: Boolean = false,
)
data class PluginListItem(
val plugin: PluginEntry,
val isSelected: Boolean,
val authState: PluginAuthState,
val logoPath: Path?,
)
data class AbilitySelection(
val ability: PluginAbility,
val plugins: List<PluginEntry>,
val selectedPlugin: PluginEntry?,
)
data class PluginDiscoverState(
val repos: List<GitHubRepo> = emptyList(),
val currentPage: Int = 1,
val hasMore: Boolean = true,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val error: String? = null,
val installingRepoId: Long? = null,
val isInitialLoaded: Boolean = false,
)
sealed interface UrlError {
data object Empty : UrlError
data object InvalidScheme : UrlError
data object DownloadFailed : UrlError
data class Message(val message: String) : UrlError
}
sealed interface PluginUiState {
data object Loading : PluginUiState
data class Data(
val plugins: List<PluginListItem>,
val abilitySelections: List<AbilitySelection>,
val pendingPlugin: PluginManager.PendingPlugin? = null,
val pendingPluginLogoPath: Path? = null,
val urlInput: String = "",
val urlError: UrlError? = null,
val isLoadingUrl: Boolean = false,
val showInstallSheet: Boolean = false,
val showPluginInfo: PluginEntry? = null,
val pluginInfoLogoPath: Path? = null,
val showPluginSupport: PluginEntry? = null,
val supportText: String? = null,
val isLoadingSupport: Boolean = false,
val installDialogRepo: GitHubRepo? = null,
val releases: List<GitHubRelease> = emptyList(),
val isLoadingReleases: Boolean = false,
val discover: PluginDiscoverState = PluginDiscoverState(),
) : PluginUiState
}
private data class PluginManagerSnapshot(
val state: PluginManagerStates,
val pendingPlugin: PluginManager.PendingPlugin?,
val metadataPlugins: List<PluginEntry>,
val audioPlugins: List<PluginEntry>,
val lyricsPlugins: List<PluginEntry>,
val scrobblePlugins: List<PluginEntry>,
val activeServices: Map<PluginAbility, PluginService?>?,
)
private data class PluginScreenState(
val urlInput: String = "",
val urlError: UrlError? = null,
val isLoadingUrl: Boolean = false,
val showInstallSheet: Boolean = false,
val showPluginInfo: PluginEntry? = null,
val pluginInfoLogoPath: Path? = null,
val showPluginSupport: PluginEntry? = null,
val supportText: String? = null,
val isLoadingSupport: Boolean = false,
val installDialogRepo: GitHubRepo? = null,
val releases: List<GitHubRelease> = emptyList(),
val isLoadingReleases: Boolean = false,
val discover: PluginDiscoverState = PluginDiscoverState(),
)
@OptIn(ExperimentalCoroutinesApi::class)
class PluginViewModel(
private val pluginManager: PluginManager,
private val webViewController: WebViewController,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<PluginViewModel>()
private val gitHubRepo = GitHubPluginRepository()
private val _screenState = MutableStateFlow(PluginScreenState())
private val _allRepos = mutableListOf<GitHubRepo>()
private val _paginationInfo = PaginationInfo()
init {
viewModelScope.launch {
pluginManager.state.collect { pluginState ->
if (pluginState is PluginManagerStates.Data) {
val installedUrls = pluginState.plugins
.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }
.toSet()
_screenState.update {
it.copy(
discover = it.discover.copy(
repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls }
)
)
}
}
}
}
loadFirstDiscoverPage()
}
private class PaginationInfo(
var currentPage: Int = 1,
var hasMore: Boolean = true,
var totalCount: Int = 0,
)
@Suppress("UNCHECKED_CAST")
private val pluginManagerSnapshot: StateFlow<PluginManagerSnapshot> = combine(
pluginManager.state,
pluginManager.pendingPlugin,
pluginManager.metadataPlugins,
pluginManager.audioPlugins,
pluginManager.lyricsPlugins,
pluginManager.scrobblePlugins,
pluginManager.ziplineServices,
) { values ->
PluginManagerSnapshot(
state = values[0] as PluginManagerStates,
pendingPlugin = values[1] as PluginManager.PendingPlugin?,
metadataPlugins = values[2] as List<PluginEntry>,
audioPlugins = values[3] as List<PluginEntry>,
lyricsPlugins = values[4] as List<PluginEntry>,
scrobblePlugins = values[5] as List<PluginEntry>,
activeServices = values[6] as Map<PluginAbility, PluginService?>?,
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
PluginManagerSnapshot(
state = PluginManagerStates.Loading,
pendingPlugin = null,
metadataPlugins = emptyList(),
audioPlugins = emptyList(),
lyricsPlugins = emptyList(),
scrobblePlugins = emptyList(),
activeServices = null,
)
)
private val pluginLogoPaths: StateFlow<Map<String, Path?>> = pluginManager.state
.filterIsInstance<PluginManagerStates.Data>()
.map { state ->
state.plugins.associate { plugin ->
plugin.id to getLogoPath(plugin.id)
}
}
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
private val pluginAuthStates: StateFlow<Map<String, PluginAuthState>> = pluginManager.state
.filterIsInstance<PluginManagerStates.Data>()
.map { it.plugins }
.distinctUntilChanged()
.flatMapLatest { plugins ->
if (plugins.isEmpty()) return@flatMapLatest flowOf(emptyMap())
val entries = plugins.associate { plugin ->
plugin.id to pluginAuthFlow(plugin)
}
combine(entries.values.toList()) { states ->
entries.keys.zip(states).toMap()
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
val uiState: StateFlow<PluginUiState> = combine(
pluginManagerSnapshot,
_screenState,
pluginAuthStates,
pluginLogoPaths,
) { snapshot, screen, authStates, logoPaths ->
when (val state = snapshot.state) {
is PluginManagerStates.Loading -> PluginUiState.Loading
is PluginManagerStates.Data -> PluginUiState.Data(
plugins = state.plugins.map { plugin ->
PluginListItem(
plugin = plugin,
isSelected = state.selectedPlugins.containsValue(plugin),
authState = authStates[plugin.id] ?: PluginAuthState(),
logoPath = logoPaths[plugin.id],
)
},
abilitySelections = listOf(
AbilitySelection(
PluginAbility.METADATA,
snapshot.metadataPlugins,
state.selectedPlugins[PluginAbility.METADATA]
),
AbilitySelection(
PluginAbility.AUDIO,
snapshot.audioPlugins,
state.selectedPlugins[PluginAbility.AUDIO]
),
AbilitySelection(
PluginAbility.LYRICS,
snapshot.lyricsPlugins,
state.selectedPlugins[PluginAbility.LYRICS]
),
AbilitySelection(
PluginAbility.SCROBBLE,
snapshot.scrobblePlugins,
state.selectedPlugins[PluginAbility.SCROBBLE]
),
),
pendingPlugin = snapshot.pendingPlugin,
pendingPluginLogoPath = snapshot.pendingPlugin?.entry?.id?.let { getLogoPath(it) },
urlInput = screen.urlInput,
urlError = screen.urlError,
isLoadingUrl = screen.isLoadingUrl,
showInstallSheet = screen.showInstallSheet,
showPluginInfo = screen.showPluginInfo,
pluginInfoLogoPath = screen.pluginInfoLogoPath,
showPluginSupport = screen.showPluginSupport,
supportText = screen.supportText,
isLoadingSupport = screen.isLoadingSupport,
installDialogRepo = screen.installDialogRepo,
releases = screen.releases,
isLoadingReleases = screen.isLoadingReleases,
discover = screen.discover,
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PluginUiState.Loading)
private fun pluginAuthFlow(plugin: PluginEntry): Flow<PluginAuthState> {
return combine(pluginManager.state, pluginManager.ziplineServices) { state, services ->
val data = state as? PluginManagerStates.Data
val ability = data?.selectedPlugins?.entries
?.firstOrNull { (_, selected) -> selected.id == plugin.id }
?.key
ability?.let { services?.get(it) }
}.distinctUntilChanged()
.flatMapLatest { service ->
if (service == null) return@flatMapLatest flowOf(PluginAuthState())
flow {
val requiresAuth = try {
service.use { coreAPI.requiresAuthentication }
} catch (e: Exception) {
if (e is CancellationException) throw e
// Service may have been stopped/closed concurrently when the
// plugin selection changed. Fall back to a default state and wait
// for the next service emission.
false
}
emit(PluginAuthState(requiresAuth = requiresAuth, isLoggedIn = false))
if (requiresAuth) {
service.loggedInFlow.collect { loggedIn ->
emit(PluginAuthState(requiresAuth = true, isLoggedIn = loggedIn))
}
}
}
}
}
private fun getLogoPath(pluginId: String): Path? {
val path = pluginManager.pluginsDirPath / pluginId.toPath() / "logo.png".toPath()
return if (FileSystem.SYSTEM.exists(path)) path else null
}
private fun findAbilityForPlugin(pluginId: String): PluginAbility? {
val state = pluginManagerSnapshot.value.state as? PluginManagerStates.Data
return state?.selectedPlugins?.entries
?.firstOrNull { (_, plugin) -> plugin.id == pluginId }
?.key
}
private fun findServiceForPlugin(pluginId: String): PluginService? {
val ability = findAbilityForPlugin(pluginId) ?: return null
return pluginManagerSnapshot.value.activeServices?.get(ability)
}
fun onUrlInputChange(input: String) {
_screenState.update { it.copy(urlInput = input, urlError = null) }
}
fun submitUrl() {
val url = _screenState.value.urlInput.trim()
when {
url.isBlank() -> {
_screenState.update { it.copy(urlError = UrlError.Empty) }
return
}
!url.startsWith("http://") && !url.startsWith("https://") -> {
_screenState.update { it.copy(urlError = UrlError.InvalidScheme) }
return
}
}
_screenState.update { it.copy(urlError = null, isLoadingUrl = true) }
viewModelScope.launch {
runCatching {
pluginManager.addPluginFromURL(url)
}.onSuccess {
_screenState.update { it.copy(urlInput = "", isLoadingUrl = false) }
}.onFailure { e ->
logger.e(e) { "Failed to add plugin from URL" }
_screenState.update {
it.copy(
urlError = e.message?.let { message -> UrlError.Message(message) }
?: UrlError.DownloadFailed,
isLoadingUrl = false,
)
}
}
}
}
fun onFileSelected(file: PlatformFile?) {
if (file == null) return
viewModelScope.launch {
runCatching {
pluginManager.preparePlugin(file.readBytes())
}.onFailure { e ->
logger.e(e) { "Failed to prepare plugin from file" }
}
}
}
fun showInstallSheet() {
_screenState.update { it.copy(showInstallSheet = true) }
}
fun dismissInstallSheet() {
_screenState.update { it.copy(showInstallSheet = false) }
}
fun showPluginInfo(plugin: PluginEntry) {
_screenState.update {
it.copy(
showPluginInfo = plugin,
pluginInfoLogoPath = getLogoPath(plugin.id),
)
}
}
fun dismissPluginInfo() {
_screenState.update { it.copy(showPluginInfo = null, pluginInfoLogoPath = null) }
}
fun loadSupport(plugin: PluginEntry) {
_screenState.update {
it.copy(
showPluginSupport = plugin,
isLoadingSupport = true,
supportText = null,
)
}
viewModelScope.launch {
val service = findServiceForPlugin(plugin.id)
if (service == null) {
_screenState.update { it.copy(isLoadingSupport = false) }
return@launch
}
runCatching {
service.use { coreAPI.supportMarkdownText(SemVer.parse(plugin.version)) }
}.onSuccess { text ->
_screenState.update { it.copy(supportText = text, isLoadingSupport = false) }
}.onFailure { e ->
logger.e(e) { "Failed to load support text" }
_screenState.update { it.copy(isLoadingSupport = false) }
}
}
}
fun dismissSupport() {
_screenState.update {
it.copy(
showPluginSupport = null,
supportText = null,
isLoadingSupport = false,
)
}
}
fun showInstallDialog(repo: GitHubRepo) {
_screenState.update {
it.copy(
installDialogRepo = repo,
isLoadingReleases = true,
releases = emptyList(),
)
}
viewModelScope.launch {
runCatching {
val parts = repo.fullName.split("/")
gitHubRepo.getReleases(parts[0], parts[1])
}.onSuccess { releases ->
_screenState.update { it.copy(releases = releases, isLoadingReleases = false) }
}.onFailure { e ->
logger.e(e) { "Failed to load releases" }
_screenState.update { it.copy(isLoadingReleases = false) }
}
}
}
fun dismissInstallDialog() {
_screenState.update { it.copy(installDialogRepo = null, releases = emptyList()) }
}
fun selectPlugin(ability: PluginAbility, plugin: PluginEntry?) {
pluginManager.setSelectedPlugin(ability, plugin)
}
fun removePlugin(plugin: PluginEntry) {
viewModelScope.launch {
runCatching {
pluginManager.removePlugin(plugin)
}.onFailure { e ->
logger.e(e) { "Failed to remove plugin" }
}
}
}
fun login(plugin: PluginEntry) {
val service = findServiceForPlugin(plugin.id) ?: return
pluginManager.launchTask {
service.use { coreAPI.login() }
}
}
fun logout(plugin: PluginEntry) {
val service = findServiceForPlugin(plugin.id) ?: return
pluginManager.launchTask {
service.use { coreAPI.logout() }
}
viewModelScope.launch {
webViewController.clearData(plugin.id)
}
}
fun confirmInstall() {
pluginManager.confirmInstall()
}
fun dismissInstall() {
pluginManager.dismissInstall()
}
private fun filterInstalledRepos(repos: List<GitHubRepo>): List<GitHubRepo> {
val pluginState = pluginManager.state.value
if (pluginState !is PluginManagerStates.Data) return repos
val installedUrls = pluginState.plugins
.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }
.toSet()
return repos.filter { it.htmlUrl !in installedUrls }
}
private fun loadFirstDiscoverPage() {
viewModelScope.launch {
_screenState.update {
it.copy(discover = it.discover.copy(isLoading = true, error = null))
}
runCatching {
gitHubRepo.searchSpotubePlugins(page = 1)
}.onSuccess { response ->
_allRepos.clear()
_allRepos.addAll(response.items)
_paginationInfo.currentPage = 1
_paginationInfo.totalCount = response.totalCount
_paginationInfo.hasMore = _allRepos.size < response.totalCount
_screenState.update {
it.copy(
discover = it.discover.copy(
repos = filterInstalledRepos(response.items),
currentPage = 1,
hasMore = _paginationInfo.hasMore,
isLoading = false,
isInitialLoaded = true,
)
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load plugins" }
_screenState.update {
it.copy(
discover = it.discover.copy(
isLoading = false,
error = e.message,
isInitialLoaded = true,
)
)
}
}
}
}
fun loadNextDiscoverPage() {
val current = _screenState.value.discover
if (current.isLoadingMore || !current.hasMore) return
viewModelScope.launch {
val nextPage = _paginationInfo.currentPage + 1
_screenState.update {
it.copy(discover = it.discover.copy(isLoadingMore = true, error = null))
}
runCatching {
gitHubRepo.searchSpotubePlugins(page = nextPage)
}.onSuccess { response ->
_allRepos.addAll(response.items)
_paginationInfo.currentPage = nextPage
_paginationInfo.hasMore = _allRepos.size < response.totalCount
_screenState.update {
it.copy(
discover = it.discover.copy(
repos = filterInstalledRepos(_allRepos),
currentPage = nextPage,
hasMore = _paginationInfo.hasMore,
isLoadingMore = false,
)
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more plugins" }
_screenState.update {
it.copy(
discover = it.discover.copy(
isLoadingMore = false,
error = e.message,
)
)
}
}
}
}
fun installPlugin(release: GitHubRelease, repoId: Long) {
val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
if (smplugUrl != null) {
installPluginFromUrl(smplugUrl, repoId)
}
}
private fun installPluginFromUrl(url: String, repoId: Long) {
if (_screenState.value.discover.installingRepoId != null) return
_screenState.update {
it.copy(discover = it.discover.copy(installingRepoId = repoId, error = null))
}
viewModelScope.launch {
runCatching {
pluginManager.addPluginFromURL(url)
}.onFailure { e ->
logger.e(e) { "Failed to install plugin" }
_screenState.update {
it.copy(discover = it.discover.copy(error = e.message))
}
}
_screenState.update {
it.copy(discover = it.discover.copy(installingRepoId = null))
}
}
}
override fun onCleared() {
gitHubRepo.close()
super.onCleared()
}
}

View File

@ -18,16 +18,34 @@
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
@ -36,14 +54,56 @@ 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 currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.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 dataState = state as? SavedTracksScreenState.Data
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
@ -73,24 +133,40 @@ fun SavedTracksScreen(
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
currentTrackId = trackOptionsContext.currentTrackId,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
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 = viewModel::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
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
},
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
},
)

View File

@ -34,15 +34,10 @@ 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
@ -51,7 +46,6 @@ 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
@ -94,9 +88,6 @@ 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>()
@ -105,36 +96,12 @@ 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(
@ -145,7 +112,6 @@ class SavedTracksViewModel(
repository.savedTracksIdsFlow
) { _, _ -> }.collect {
loadInitialData()
loadCurrentUser()
}
}
blacklistRepository.blacklistedTracks
@ -156,14 +122,6 @@ 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()
@ -224,10 +182,20 @@ class SavedTracksViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
@ -242,48 +210,12 @@ class SavedTracksViewModel(
}
is TrackOptionsAction.ToggleFavorite -> {}
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
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 {

View File

@ -171,10 +171,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {

View File

@ -20,10 +20,6 @@ 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
@ -32,20 +28,14 @@ class SettingsRepository(private val database: Database) {
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val userSettings: Flow<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 ->

View File

@ -32,7 +32,11 @@ interface SettingsProvider {
class SettingsViewModel(
private val repository: SettingsRepository
) : ViewModel(), SettingsProvider {
override val settingsState = repository.userSettings
override val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
null,
)
fun updateSettings(transform: UserSettings.() -> UserSettings) {
viewModelScope.launch {

View File

@ -95,9 +95,8 @@ class AlternativeTrackContentViewModel(
}
fun selectAlternative(source: AudioSource) {
val currentTrack =
(audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
?: return
val currentTrack = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
if (currentTrack == null) return
viewModelScope.launch {
alternativeTracksRepository.selectAlternative(currentTrack, source)
activeSourceIdFlow.value = source.id

View File

@ -19,6 +19,9 @@ 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
@ -26,6 +29,7 @@ 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
@ -40,54 +44,128 @@ 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 state by viewModel.queueContentUiState.collectAsState()
val displayItems = state.displayItems
val filterQuery = state.filterQuery
val isFiltered = state.isFiltered
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 lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(
val reorderableLazyListState =
rememberReorderableLazyListState(
lazyListState,
onMove = { from, to ->
if (isFiltered) return@rememberReorderableLazyListState
viewModel.onMove(from.index, to.index)
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
},
)
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
@ -126,7 +204,7 @@ fun PlayerQueueContent(
}
}
if (displayItems.isEmpty()) {
if (displayList.isEmpty()) {
Text(
text = "No queue entries",
style = MaterialTheme.typography.bodyMedium,
@ -139,7 +217,7 @@ fun PlayerQueueContent(
state = lazyListState,
contentPadding = PaddingValues(bottom = 8.dp),
) {
items(displayItems, key = { it.id }) { item ->
items(displayList, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
QueueItemRow(
@ -147,8 +225,7 @@ fun PlayerQueueContent(
reorderScope = if (isFiltered) null else this,
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
onDragStarted = { viewModel.onDragStart() },
onDragStopped = { viewModel.onDragStop() },
onDragStopped = ::finalizeReorder,
)
}
}
@ -165,7 +242,6 @@ private fun QueueItemRow(
reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?,
onPlayClick: () -> Unit,
onRemoveClick: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
) {
var showMenu by remember { mutableStateOf(false) }
@ -187,12 +263,7 @@ private fun QueueItemRow(
.size(24.dp)
.then(
if (reorderScope != null) {
with(reorderScope) {
Modifier.draggableHandle(
onDragStarted = { onDragStarted() },
onDragStopped = onDragStopped,
)
}
with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) }
} else {
Modifier
},
@ -248,7 +319,7 @@ private fun QueueItemRow(
},
trailing = {
Text(
text = item.durationLabel,
text = item.durationMs.toDurationString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -285,3 +356,53 @@ 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')}"
}

View File

@ -30,20 +30,10 @@ 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 displayItems: List<QueueItemUi> = emptyList(),
val isFiltered: Boolean = false,
val queue: List<QueueEntry> = emptyList(),
val currentQueueEntry: QueueEntry? = null,
)
class PlayerQueueContentViewModel(
@ -51,79 +41,18 @@ 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()
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
val queueContentUiState: StateFlow<QueueContentUiState> = 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,
) { 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
}
) { queue, currentQueueEntry, filterQuery ->
QueueContentUiState(
filterQuery = filterQuery,
displayItems = buffer ?: filtered,
isFiltered = isFiltered,
queue = queue,
currentQueueEntry = currentQueueEntry,
)
}.stateIn(
scope = viewModelScope,
@ -143,6 +72,13 @@ 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 {
@ -160,67 +96,9 @@ 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')}"
}

View File

@ -17,7 +17,7 @@
package dev.krtirtho.spotube.core.webview
import dev.nucleusframework.webview.web.WebViewState
import io.github.kdroidfilter.webview.web.WebViewState
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDate
import platform.Foundation.NSHTTPCookieStorage

View File

@ -490,7 +490,6 @@ 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)
}
}

View File

@ -19,16 +19,13 @@ 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 {
@ -36,6 +33,5 @@ actual val platformModules = module {
single<AudioPlayerInterface> { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
single<ShareService> { JvmShareService() }
single { SystemTrayService(get(), get(), get()) }
single { SystemMediaControlService(get()) } withOptions { createdAtStart() }
single { SystemTrayService(get(), get(), get(), get()) }
}

View File

@ -1,148 +0,0 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.mediacontrol
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.MediaItem
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.di.injectLogger
import dev.nucleusframework.media.control.MediaControlEvent
import dev.nucleusframework.media.control.MediaControlService
import dev.nucleusframework.media.control.MediaMetadata
import dev.nucleusframework.media.control.MediaPlaybackState
import dev.nucleusframework.media.control.MediaPlaybackStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import kotlin.time.Duration.Companion.milliseconds
/**
* Publishes now-playing metadata and playback state to the OS media center
* (MPRIS on Linux, Now Playing on macOS, SMTC on Windows) and forwards media
* key commands back into the [AudioPlayerInterface].
*
* Events from the OS are delivered on the Swing EDT; playback commands are
* dispatched onto [scope] so they reach the player's suspend API.
*/
class SystemMediaControlService(
private val audioPlayer: AudioPlayerInterface,
) : KoinComponent, AutoCloseable {
private val logger by injectLogger<SystemMediaControlService>()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val enabled: Boolean = MediaControlService.isAvailable()
init {
if (enabled) {
MediaControlService.configure()
MediaControlService.attach(::onMediaControlEvent)
observePlayer()
} else {
logger.i { "System media control backend unavailable; skipping integration" }
}
}
private fun onMediaControlEvent(event: MediaControlEvent) {
when (event) {
MediaControlEvent.Play -> scope.launch { audioPlayer.play() }
MediaControlEvent.Pause -> scope.launch { audioPlayer.pause() }
MediaControlEvent.Toggle -> scope.launch {
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
audioPlayer.pause()
} else {
audioPlayer.play()
}
}
MediaControlEvent.Next -> scope.launch { audioPlayer.skipToNext() }
MediaControlEvent.Previous -> scope.launch { audioPlayer.skipToPrevious() }
MediaControlEvent.Stop -> scope.launch { audioPlayer.stop() }
is MediaControlEvent.SeekBy -> scope.launch {
audioPlayer.seekTo(audioPlayer.positionFlow.value + event.offsetMs.milliseconds)
}
is MediaControlEvent.SetPosition -> scope.launch {
audioPlayer.seekTo(event.positionMs.milliseconds)
}
is MediaControlEvent.SetVolume -> scope.launch {
audioPlayer.setVolume(event.volume.toFloat())
}
is MediaControlEvent.OpenUri,
MediaControlEvent.Raise,
MediaControlEvent.Quit,
-> Unit
}
}
private fun observePlayer() {
scope.launch {
audioPlayer.currentMediaItemFlow.collect { item ->
if (item != null) {
MediaControlService.setMetadata(item.toMediaMetadata())
}
}
}
scope.launch {
combine(
audioPlayer.playerStateFlow,
audioPlayer.positionFlow,
) { state, position ->
MediaPlaybackState(
status = state.toMediaPlaybackStatus(),
positionMs = position.inWholeMilliseconds,
)
}.collect { MediaControlService.setPlaybackState(it) }
}
scope.launch {
audioPlayer.volumeFlow.collect { volume ->
MediaControlService.setVolume(volume.toDouble())
}
}
}
private fun MediaItem.toMediaMetadata(): MediaMetadata {
return MediaMetadata(
title = title,
artist = artist,
album = album,
coverUrl = coverURL.ifBlank { null },
duration = duration.inWholeMilliseconds,
)
}
private fun PlayerState.toMediaPlaybackStatus(): MediaPlaybackStatus {
return when (this) {
PlayerState.PLAYING -> MediaPlaybackStatus.PLAYING
PlayerState.PAUSED -> MediaPlaybackStatus.PAUSED
else -> MediaPlaybackStatus.STOPPED
}
}
override fun close() {
if (enabled) {
MediaControlService.detach()
}
scope.cancel()
}
}

View File

@ -1,90 +0,0 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.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() }
}
}

View File

@ -20,40 +20,81 @@ 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.stateIn
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
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,
) : AutoCloseable {
) : KoinComponent, AutoCloseable {
private val logger by injectLogger<SystemTrayService>()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val volumeStep = 0.05f
private var trayIcon: TrayIcon? = null
private var menuDialog: TrayMenuDialog? = null
private var windowVisible: Boolean = true
private var onToggleWindowVisibility: () -> Unit = {}
private var onExit: () -> Unit = {}
val state: StateFlow<TrayState> =
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 {
combine(
audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow,
@ -65,98 +106,335 @@ class SystemTrayService(
) { values ->
TrayState(
playerState = values[0] as PlayerState,
mediaItem = values[1] as MediaItem?,
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.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>),
)
}.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial)
}.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)
}
}
}
}
}
fun setCallbacks(onToggleWindowVisibility: () -> Unit) {
fun setCallbacks(
onToggleWindowVisibility: () -> Unit,
onExit: () -> Unit,
) {
this.onToggleWindowVisibility = onToggleWindowVisibility
this.onExit = onExit
}
fun hideWindow() = onToggleWindowVisibility()
fun togglePlayPause() {
scope.launch {
if (state.value.playerState == PlayerState.PLAYING) audioPlayer.pause() else audioPlayer.play()
fun hideWindow() {
if (windowVisible) {
windowVisible = false
onToggleWindowVisibility()
}
}
fun skipToNext() {
scope.launch { audioPlayer.skipToNext() }
fun showWindow() {
if (!windowVisible) {
windowVisible = true
onToggleWindowVisibility()
}
}
fun skipToPrevious() {
scope.launch { audioPlayer.skipToPrevious() }
private fun ensureTrayCreated() {
if (trayIcon != null) return
if (!SystemTray.isSupported()) {
logger.w { "System tray is not supported on this platform" }
return
}
fun setShuffle(enabled: Boolean) {
scope.launch { audioPlayer.shuffle(enabled) }
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 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))
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" }
}
}
trayIcon = null
}
override fun close() {
scope.cancel()
removeTray()
}
data class TrayState(
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(
val playerState: PlayerState,
val mediaItem: MediaItem?,
val mediaItem: dev.krtirtho.spotube.core.audioplayer.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(),
)
}
}
}

View File

@ -22,7 +22,6 @@ 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
@ -45,10 +44,6 @@ 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
@ -60,9 +55,9 @@ actual fun ApplicationMainBar(
actions: @Composable (RowScope.() -> Unit),
backButton: Boolean,
) {
LocalWindowScope.current.WindowDraggableArea {
Box(
modifier = Modifier.fillMaxWidth()
.windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize)
) {
TopAppBar(
title = title,
@ -77,11 +72,11 @@ actual fun ApplicationMainBar(
subtitle = subtitle,
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
)
LocalWindowScope.current.WindowControls(
modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),
renderer = WindowControlsRenderer.Platform, // default
WindowButtons(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
@Composable

View File

@ -19,8 +19,7 @@ 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< NucleusApplicationScope> {
val LocalApplicationScope = staticCompositionLocalOf<ApplicationScope> {
error("No ApplicationScope found in LocalApplicationScope")
}

View File

@ -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<NucleusDecoratedWindowScope> {
val LocalWindowScope = compositionLocalOf<FrameWindowScope> {
error("No Window found in LocalWindowScope")
}
val LocalWindowState = compositionLocalOf<WindowState> {

View File

@ -18,7 +18,7 @@
package dev.krtirtho.spotube.core.webview
import dev.krtirtho.spotube.core.paths.Paths
import dev.nucleusframework.webview.web.WebViewState
import io.github.kdroidfilter.webview.web.WebViewState
import okio.FileSystem
import okio.Path.Companion.toPath
import org.koin.core.context.GlobalContext

View File

@ -25,21 +25,18 @@ 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
@ -57,39 +54,14 @@ 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)
nucleusApplication(backend = NucleusBackend.Tao) {
application {
val windowState = rememberWindowState(
width = 1080.dp,
height = 720.dp
@ -103,24 +75,16 @@ fun main() {
var isWindowVisible by remember { mutableStateOf(true) }
val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }
val onExit = {
trayService.setCallbacks(
onToggleWindowVisibility = { isWindowVisible = !isWindowVisible },
onExit = {
trayService.close()
appScope.cancel()
exitApplication()
}
trayService.setCallbacks(onToggleWindowVisibility)
if (minimizeToTray) {
SystemTray(
isWindowVisible = isWindowVisible,
onToggleWindowVisibility = onToggleWindowVisibility,
onExit = onExit,
)
}
DecoratedWindow(
Window(
state = windowState,
onCloseRequest = {
if (minimizeToTray) {
@ -132,11 +96,12 @@ fun main() {
}
},
title = "Spotube",
decoration = WindowDecoration.Undecorated(),
visible = isWindowVisible,
) {
CompositionLocalProvider(
LocalApplicationScope provides this@nucleusApplication,
LocalWindowScope provides this@DecoratedWindow,
LocalApplicationScope provides this@application,
LocalWindowScope provides this@Window,
LocalWindowState provides windowState
) {
App()

View File

@ -20,56 +20,55 @@ android-minSdk = "28"
android-targetSdk = "36"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.19.0"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.11.0"
androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0"
appdirs = "1.5.0"
cache4k = "0.14.0"
carApp = "1.7.0"
composeHotReload = "1.2.0"
composeMultiplatform = "1.11.1"
coil = "3.5.0"
composeHotReload = "1.1.0"
composeMultiplatform = "1.10.3"
coil = "3.4.0"
composePlaceholderMaterial3 = "1.0.12"
composeShimmer = "1.5.0"
composeShimmer = "1.5.0-beta02"
desugar_jdk_libs = "2.1.5"
haze = "2.0.0-alpha03"
haze = "2.0.0-alpha01"
jna = "5.18.1"
junit = "4.13.2"
kermit = "2.1.0"
kmpZip = "0.13.0"
kmpZip = "0.10.0"
kotlin = "2.3.21"
kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.1"
kotlinx-coroutines = "1.10.2"
kotlinx-io = "0.9.0"
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.3.0"
nucleusNucleusApplication = "2.4.6"
newpipeExtractorKmp = "1.2.1"
semver = "2.1.0"
vlcj = "4.12.1"
vlcjNative = "4.12.0"
ziplineVersion = "1.27.0"
ktor = "3.5.1"
kotlinStdlib = "2.4.10"
ktor = "3.4.3"
jetbrainsKotlinJvm = "2.3.21"
kotlinStdlib = "2.3.21"
runner = "1.7.0"
core = "1.7.0"
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"
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"
feather-icons = "1.1.1"
material3-window-size = "1.9.0"
kotlinx-datetime = "0.8.0"
media3 = "1.10.1"
kotlinx-datetime = "0.7.1"
media3 = "1.8.0"
androidx-datastore = "1.2.1"
ksp = "2.3.5"
spotube-gradle = "0.1.1"
spotube-gradle = "0.1.0"
cryptography = "0.6.0"
reorderable = "3.1.0"
vlcjBundler = "0.1.0"
@ -121,10 +120,6 @@ 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" }
@ -146,8 +141,7 @@ 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 = "dev.nucleusframework:composewebview", version.ref = "compose-webview" }
compose-native-tray = { module = "dev.nucleusframework:composenativetray", version.ref = "composeNativeTray" }
compose-webview = { module = "io.github.kdroidfilter:composewebview", version.ref = "compose-webview" }
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" }
@ -179,7 +173,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 = "kotlin" }
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
mavenPublish = { id = "maven-publish" }
@ -190,4 +184,3 @@ 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" }

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

0
gradlew vendored Executable file → Normal file
View File

View File

@ -20,7 +20,6 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
mavenLocal()
google {
mavenContent {
includeGroupAndSubgroups("androidx")
@ -30,13 +29,13 @@ pluginManagement {
}
mavenCentral()
gradlePluginPortal()
mavenLocal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenLocal()
google {
mavenContent {
includeGroupAndSubgroups("androidx")
@ -46,6 +45,7 @@ dependencyResolutionManagement {
}
mavenCentral()
maven("https://jitpack.io")
mavenLocal()
}
}