Compare commits

...

18 Commits

Author SHA1 Message Date
androidkotlindev
5ba51bdbb4
Merge 729bdd43b9 into 94ec831a96 2026-08-17 07:03:15 -04:00
Kingkor Roy Tirtho
94ec831a96 feat(mediacontrol): implement SystemMediaControlService for OS media integration 2026-08-16 15:07:28 +06:00
Kingkor Roy Tirtho
7b506b49e6 refactor(plugin): replace PluginDiscoverViewModel with PluginViewModel and update navigation 2026-08-16 14:19:01 +06:00
Kingkor Roy Tirtho
11cc5e2457 fix(plugin): handle service closure exception during authentication check 2026-08-16 12:27:07 +06:00
Kingkor Roy Tirtho
0a76abba65 chore: update Gradle wrapper to version 9.4.0 and Kotlin standard library to 2.4.10 2026-08-16 11:57:10 +06:00
Kingkor Roy Tirtho
95e9d563ec fix: add mavenLocal() to plugin and dependency resolution management 2026-08-15 22:01:56 +06:00
Kingkor Roy Tirtho
e826052691 fix(webview): linux webview crashing the app 2026-08-15 21:59:15 +06:00
Kingkor Roy Tirtho
e0b9ab530d feat: implement system tray functionality with Compose Native Tray integration 2026-08-14 21:41:09 +06:00
Kingkor Roy Tirtho
94903dcc33 feat: integrate Nucleus framework for enhanced window management and webview support 2026-08-14 20:40:37 +06:00
Kingkor Roy Tirtho
d95e1aad70 fix: update pendingNextIndex when playing a track from the playlist 2026-08-14 19:08:01 +06:00
Kingkor Roy Tirtho
19c1d0ec35 refactor: streamline PlayerQueueContent and ViewModel for better state management 2026-08-14 18:35:42 +06:00
Kingkor Roy Tirtho
870195bdfd feat: improve NewPipe video rank matching 2026-08-14 17:29:25 +06:00
Kingkor Roy Tirtho
4ef256249e fix: play next not working 2026-08-14 17:17:14 +06:00
Kingkor Roy Tirtho
f4eac7dbd9 refactor: make sure screens use viewmodels instead of having logic in composables 2026-08-14 17:11:38 +06:00
Kingkor Roy Tirtho
16e2a743a5 fix: cached track audio not being invalidated causing alternative track source to not being played 2026-08-14 13:15:34 +06:00
Kingkor Roy Tirtho
b6615bdbc8 feat: update dependencies and configure compose hot reload settings 2026-08-13 22:03:41 +06:00
Kingkor Roy Tirtho
729bdd43b9
Merge branch 'dev' into libjsoncpp26-support 2025-09-08 16:15:47 +06:00
neonItem
21afee3cfb
Fix dependencies on Debian Testing/Unstable (rolling release) systems 2025-03-22 23:54:28 +02:00
50 changed files with 3071 additions and 2669 deletions

View File

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

View File

@ -20,6 +20,7 @@
## Dependencies ## Dependencies
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. - `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`). - 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 ## 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/`. - JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`.
@ -30,6 +31,7 @@
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. - `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
## UI component patterns ## 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. - **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. - **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. - 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,4 +33,5 @@ plugins {
alias(libs.plugins.uniffi) apply false alias(libs.plugins.uniffi) apply false
alias(libs.plugins.cargo) apply false alias(libs.plugins.cargo) apply false
alias(libs.plugins.mokkery) 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.GobleyHost
import gobley.gradle.cargo.dsl.jvm import gobley.gradle.cargo.dsl.jvm
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import dev.nucleusframework.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream import java.io.FileInputStream
@ -35,6 +35,7 @@ plugins {
alias(libs.plugins.uniffi) alias(libs.plugins.uniffi)
alias(libs.plugins.cargo) alias(libs.plugins.cargo)
alias(libs.plugins.mokkery) alias(libs.plugins.mokkery)
alias(libs.plugins.neucleusFramework)
kotlin("plugin.atomicfu") version libs.versions.kotlin kotlin("plugin.atomicfu") version libs.versions.kotlin
} }
@ -220,6 +221,12 @@ kotlin {
implementation(libs.appdirs) implementation(libs.appdirs)
implementation(libs.jna) implementation(libs.jna)
implementation(libs.nucleus.core.runtime)
implementation(libs.nucleus.nucleus.application)
implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray)
implementation(libs.nucleus.media.control)
} }
} }
} }
@ -280,61 +287,68 @@ dependencies {
debugImplementation(libs.compose.uiTooling) debugImplementation(libs.compose.uiTooling)
} }
compose.desktop { nucleus.application {
application { mainClass = "dev.krtirtho.spotube.MainKt"
mainClass = "dev.krtirtho.spotube.MainKt"
jvmArgs += listOf( jvmArgs += listOf(
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
// --- ADD THESE FOR SPEED & LOW RAM --- // --- ADD THESE FOR SPEED & LOW RAM ---
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
"-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) "-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room)
"-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup! "-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB "-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
)
nativeDistributions {
packageName = "dev.krtirtho.spotube"
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
targetFormats(
TargetFormat.Dmg,
TargetFormat.Deb,
TargetFormat.Rpm,
TargetFormat.AppImage,
TargetFormat.Msi,
TargetFormat.Exe,
) )
nativeDistributions { modules("jdk.unsupported")
packageName = "dev.krtirtho.spotube"
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
licenseFile = project.file("../LICENSE")
targetFormats( homepage = "https://spotube.cc"
TargetFormat.Dmg,
TargetFormat.Deb,
TargetFormat.Rpm,
TargetFormat.AppImage,
TargetFormat.Msi,
TargetFormat.Exe,
)
modules("jdk.unsupported") linux {
modules("jdk.security.auth")
linux { debMaintainer = "Team Spotube <team.spotube@proton.me>"
modules("jdk.security.auth") rpmLicenseType = "AGPL-3.0-or-later"
} }
windows { windows {
shortcut = true shortcut = true
menu = true menu = true
dirChooser = true dirChooser = true
perUserInstall = true perUserInstall = true
}
macOS {
notarization {
}
}
} }
buildTypes.release.proguard { macOS {
configurationFiles.from(project.file("proguard-rules.pro")) notarization {
}
} }
} }
buildTypes.release.proguard {
configurationFiles.from(project.file("proguard-rules.pro"))
}
} }
// Issue with compose plugin // 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 // 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 { tasks.withType<ComposeHotRun>().configureEach {
jvmArgs(
"--enable-native-access=ALL-UNNAMED"
)
systemProperty( systemProperty(
"compose.application.resources.dir", "compose.application.resources.dir",
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get() 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.CookieManager
import android.webkit.WebStorage import android.webkit.WebStorage
import android.webkit.WebView import android.webkit.WebView
import io.github.kdroidfilter.webview.web.WebViewState import dev.nucleusframework.webview.web.WebViewState
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -41,6 +42,7 @@ import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHome import dev.krtirtho.spotube.resources.iconsax.IconsaxHome
import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken
@ -89,7 +91,11 @@ val tabs = listOf(
) )
) )
@OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class, ExperimentalSharedTransitionApi::class) @OptIn(
KoinExperimentalAPI::class,
ExperimentalCoroutinesApi::class,
ExperimentalSharedTransitionApi::class
)
@Composable @Composable
fun App( fun App(
content: @Composable () -> Unit = {} content: @Composable () -> Unit = {}
@ -108,19 +114,29 @@ fun App(
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
val baseUITheme = rememberBaseUITheme() val baseUITheme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) { CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
AppShell(navigator, navigationState) { Box(modifier = Modifier.fillMaxSize()) {
SharedTransitionLayout { val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout, Box(Modifier.fillMaxSize()) {
) { AppShell(navigator, navigationState) {
Column { SharedTransitionLayout {
NavDisplay( CompositionLocalProvider(
modifier = Modifier.fillMaxSize(), LocalSharedTransitionScope provides this@SharedTransitionLayout,
onBack = navigator::pop, ) {
entries = navigationState.toEntries(koinEntryProvider()) Column {
) NavDisplay(
modifier = Modifier.fillMaxSize(),
onBack = navigator::pop,
entries = navigationState.toEntries(koinEntryProvider())
)
}
}
} }
} }
when (currentRoute) {
Routes.WebView -> WebViewScreen(koinInject())
else -> {}
}
} }
} }
content() content()

View File

@ -259,12 +259,16 @@ class DeviceAudioPlayerQueue(
return return
} }
val queue = queueFlow.value 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 -> val index = queue.indexOfFirst { entry ->
when { when (entry) {
entry is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> is QueueEntry.StreamingTrack if current is QueueEntry.StreamingTrack ->
entry.track.id == current.track.id entry.track.id == current.track.id
entry is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack ->
is QueueEntry.LocalTrack if current is QueueEntry.LocalTrack ->
entry.url == current.url && entry.name == current.name entry.url == current.url && entry.name == current.name
else -> false else -> false
} }
} }

View File

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

View File

@ -18,9 +18,8 @@
package dev.krtirtho.spotube.core.navigation package dev.krtirtho.spotube.core.navigation
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import dev.krtirtho.spotube.modules.artist.ArtistScreen
import dev.krtirtho.spotube.modules.album.AlbumScreen import dev.krtirtho.spotube.modules.album.AlbumScreen
import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.artist.ArtistScreen
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.home.HomeScreen
import dev.krtirtho.spotube.modules.library.LibraryScreen import dev.krtirtho.spotube.modules.library.LibraryScreen
@ -31,7 +30,6 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.search.SearchScreen
import dev.krtirtho.spotube.modules.settings.SettingsScreen import dev.krtirtho.spotube.modules.settings.SettingsScreen
import dev.krtirtho.spotube.modules.webview.WebViewScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
@ -95,11 +93,9 @@ val navigationModule = module {
SettingsScreen(settingsViewModel = koinViewModel()) SettingsScreen(settingsViewModel = koinViewModel())
} }
navigation<Routes.Plugins> { navigation<Routes.Plugins> {
PluginScreen(pluginManager = get()) PluginScreen(viewModel = koinViewModel())
}
navigation<Routes.WebView> {
WebViewScreen(get())
} }
navigation<Routes.WebView> {}
navigation<Routes.Playlist> { navigation<Routes.Playlist> {
PlaylistScreen( PlaylistScreen(
playlistId = it.playlistId, playlistId = it.playlistId,
@ -109,9 +105,6 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(), navigationCommands = get(),
) )
} }
@ -123,9 +116,6 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(), navigationCommands = get(),
) )
} }
@ -138,9 +128,6 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(), navigationCommands = get(),
) )
} }
@ -155,9 +142,6 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(), navigationCommands = get(),
) )
} }

View File

@ -29,6 +29,7 @@ class AlternativeTracksRepository(
private val matchedTracksRepository: MatchedTracksRepository, private val matchedTracksRepository: MatchedTracksRepository,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
) : KoinComponent { ) : KoinComponent {
private val logger by injectLogger<AlternativeTracksRepository>() private val logger by injectLogger<AlternativeTracksRepository>()
@ -78,6 +79,7 @@ class AlternativeTracksRepository(
matchedTracksRepository.saveTrackSource(track, basic) matchedTracksRepository.saveTrackSource(track, basic)
streamingUrlRepository.invalidateCachedStreamUrl(trackId) streamingUrlRepository.invalidateCachedStreamUrl(trackId)
streamingUrlRepository.invalidateCachedAlternatives(trackId) streamingUrlRepository.invalidateCachedAlternatives(trackId)
cacheManager.invalidateCacheEntry(trackId)
audioPlayerQueue.reloadCurrent() audioPlayerQueue.reloadCurrent()
logger.d { "Alternative source selection complete for track $trackId" } logger.d { "Alternative source selection complete for track $trackId" }
} }

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.Headers import io.ktor.http.Headers
import io.ktor.http.HeadersBuilder import io.ktor.http.HeadersBuilder
@ -40,12 +41,12 @@ import okio.buffer
import okio.use import okio.use
@Serializable @Serializable
internal data class CacheIndex( data class CacheIndex(
val entries: List<CacheEntry> = emptyList() val entries: List<CacheEntry> = emptyList()
) )
@Serializable @Serializable
internal data class CacheEntry( data class CacheEntry(
val trackId: String, val trackId: String,
val filename: String, val filename: String,
val sizeBytes: Long, val sizeBytes: Long,
@ -53,18 +54,26 @@ internal data class CacheEntry(
val contentType: String = "application/octet-stream", val contentType: String = "application/octet-stream",
) )
internal class CacheManager( class CacheManager(
private val paths: Paths, private val paths: Paths,
private val resolveCacheFolder: () -> String?, private val settingsRepository: SettingsRepository,
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 { companion object {
private val cacheIndexFileName = "cache_index.json".toPath() 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 { fun resolveCacheDir(): Path {
val folder = resolveCacheFolder() val folder = resolveCacheFolder()
return if (!folder.isNullOrBlank()) { return if (!folder.isNullOrBlank()) {
@ -101,6 +110,18 @@ internal class CacheManager(
return if (fileSystem.exists(filePath)) filePath to entry else null 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 { fun resolveCacheFilename(track: MetadataTrack, contentType: String?): String {
val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart() val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart()
val title = track.title.sanitizeFilenamePart() val title = track.title.sanitizeFilenamePart()

View File

@ -19,7 +19,6 @@ package dev.krtirtho.spotube.core.server
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
@ -52,10 +51,10 @@ import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
class LocalServer( class LocalServer(
private val paths: Paths,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
) : KoinComponent { ) : KoinComponent {
val logger by injectLogger<LocalServer>() val logger by injectLogger<LocalServer>()
@ -76,14 +75,6 @@ class LocalServer(
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
private val cachedCacheEnabled = MutableStateFlow(false) 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 { private val streamProxy by lazy {
StreamProxy( StreamProxy(
@ -94,7 +85,6 @@ class LocalServer(
isCachingEnabled = { cachedCacheEnabled.value }, isCachingEnabled = { cachedCacheEnabled.value },
activePort = { activePort.value }, activePort = { activePort.value },
scope = scope, scope = scope,
logger = logger,
) )
} }
@ -118,8 +108,6 @@ class LocalServer(
.collect { settings -> .collect { settings ->
if (settings != null) { if (settings != null) {
cachedCacheEnabled.value = settings.enableMusicCaching cachedCacheEnabled.value = settings.enableMusicCaching
cachedCacheFolder.value = settings.cacheFolder
cachedCacheSizeLimitMB.value = settings.cacheSizeLimitMB
} }
} }
} }

View File

@ -21,6 +21,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.request.request import io.ktor.client.request.request
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
@ -51,6 +52,7 @@ import okio.Path.Companion.toPath
import okio.SYSTEM import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import org.koin.core.component.KoinComponent
internal class StreamProxy( internal class StreamProxy(
private val httpClient: HttpClient, private val httpClient: HttpClient,
@ -60,8 +62,9 @@ internal class StreamProxy(
private val isCachingEnabled: () -> Boolean, private val isCachingEnabled: () -> Boolean,
private val activePort: () -> Int?, private val activePort: () -> Int?,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val logger: co.touchlab.kermit.Logger, ): KoinComponent {
) { val logger by injectLogger<StreamProxy>()
companion object { companion object {
private const val HOST = "127.0.0.1" private const val HOST = "127.0.0.1"
} }

View File

@ -74,6 +74,23 @@ data class TrackOptionsState(
val isBlacklisted: Boolean = false, 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 @Composable
fun TrackOptions( fun TrackOptions(
track: MetadataTrack, track: MetadataTrack,

View File

@ -17,9 +17,9 @@
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebContent import dev.nucleusframework.webview.web.WebContent
import io.github.kdroidfilter.webview.web.WebViewNavigator import dev.nucleusframework.webview.web.WebViewNavigator
import io.github.kdroidfilter.webview.cookie.CookieManager import dev.nucleusframework.webview.cookie.CookieManager
import dev.krtirtho.plugin_interfaces.host_apis.Cookie import dev.krtirtho.plugin_interfaces.host_apis.Cookie
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands

View File

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

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.webview
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -51,13 +52,14 @@ import compose.icons.feathericons.ChevronLeft
import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.ChevronRight
import compose.icons.feathericons.X import compose.icons.feathericons.X
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import io.github.kdroidfilter.webview.jsbridge.JsMessage import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge import dev.nucleusframework.webview.jsbridge.JsMessage
import io.github.kdroidfilter.webview.web.WebView import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
import io.github.kdroidfilter.webview.web.WebViewNavigator import dev.nucleusframework.webview.web.WebView
import io.github.kdroidfilter.webview.web.WebViewState import dev.nucleusframework.webview.web.WebViewNavigator
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator import dev.nucleusframework.webview.web.WebViewState
import dev.nucleusframework.webview.web.rememberWebViewNavigator
class PostMessageHandler( class PostMessageHandler(
private val onMessageReceived: (String) -> Unit = {} private val onMessageReceived: (String) -> Unit = {}
@ -149,64 +151,63 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
topBar = { topBar = {
Row( ApplicationMainBar(
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), title = {
horizontalArrangement = Arrangement.SpaceBetween, Row(
verticalAlignment = Alignment.CenterVertically modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
) { horizontalArrangement = Arrangement.SpaceBetween,
Row( verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier.height(56.dp)
) {
IconButton(
onClick = {
navigator.navigateBack()
}, enabled = navigator.canGoBack
) { ) {
Icon( Row(
FeatherIcons.ChevronLeft, verticalAlignment = Alignment.CenterVertically,
contentDescription = "Go back to browser history" horizontalArrangement = Arrangement.Start,
) modifier = Modifier.height(56.dp)
} ) {
IconButton( IconButton(
onClick = { onClick = {
navigator.navigateForward() navigator.navigateBack()
}, enabled = navigator.canGoForward }, enabled = navigator.canGoBack
) { ) {
Icon( Icon(
FeatherIcons.ChevronRight, FeatherIcons.ChevronLeft,
contentDescription = "Go forward to browser history" contentDescription = "Go back to browser history"
) )
}
IconButton(
onClick = {
navigator.navigateForward()
}, enabled = navigator.canGoForward
) {
Icon(
FeatherIcons.ChevronRight,
contentDescription = "Go forward to browser history"
)
}
}
Surface(
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
) {
BasicTextField(
value = state.lastLoadedUrl ?: "",
onValueChange = {}, // Read-only
readOnly = true,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start
),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
.wrapContentHeight(Alignment.CenterVertically)
)
}
} }
} }
Surface( )
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp), })
shape = RoundedCornerShape(18.dp), { innerPadding ->
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
) {
BasicTextField(
value = state.lastLoadedUrl ?: "",
onValueChange = {}, // Read-only
readOnly = true,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start
),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
.wrapContentHeight(Alignment.CenterVertically)
)
}
IconButton(
onClick = {
webViewController.closeWebview()
}) {
Icon(FeatherIcons.X, contentDescription = "Close WebView")
}
}
}) { innerPadding ->
WebView( WebView(
state = state, state = state,
modifier = Modifier.padding(innerPadding).fillMaxSize(), modifier = Modifier.padding(innerPadding).fillMaxSize(),
@ -216,7 +217,6 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
navigator.evaluateJavaScript(bridgeBootstrapScript) navigator.evaluateJavaScript(bridgeBootstrapScript)
}, },
factory = null
) )
} }
} }

View File

@ -40,12 +40,21 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic { private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic {
var confidence = 0f var confidence = 0f
if (track.title.lowercase() in video.title.lowercase()) confidence += 0.3f
if (track.artists.any { it.name.lowercase() in video.title.lowercase() }) confidence += 0.3f val isTitleMatch = track.title.lowercase() in video.title.lowercase()
if ((track.album != null && track.album!!.title.lowercase() in video.title.lowercase()) || video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() }
?: "") val isAlbumInTitle =
) confidence += 0.2f track.album != null && track.album!!.title.lowercase() in video.title.lowercase()
if (video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000)) confidence += 0.2f // Duration within ±30 seconds val isUploaderMatch =
video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "")
val isDurationMatch =
video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds
if (isTitleMatch) confidence += 0.3f
if (isUploaderMatch) confidence += 0.3f
if (isDurationMatch) confidence += 0.2f // Duration within ±30 seconds
if (isArtistInTitle) confidence += 0.1f
if (isAlbumInTitle) confidence += 0.1f
return AudioSource.Basic( return AudioSource.Basic(
id = video.id, id = video.id,
@ -66,19 +75,14 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
override val supportedQualities: List<AudioFormat> = listOf( override val supportedQualities: List<AudioFormat> = listOf(
AudioFormat( AudioFormat(
codec = "opus", codec = "opus", container = "webm", qualities = listOf(
container = "webm",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000), AudioQuality.Lossy(bitrate = 256_000),
) )
), ), AudioFormat(
AudioFormat( codec = "aac", container = "mp4", qualities = listOf(
codec = "aac",
container = "mp4",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
@ -89,9 +93,9 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> { override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
val isYouTubeUrl = val isYouTubeUrl =
track.externalUri != null && track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains(
(track.externalUri!!.contains("youtube.com") || "youtu.be"
track.externalUri!!.contains("youtu.be")) ))
val isYouTubeID = track.id.matches(youtubeIDRegex) val isYouTubeID = track.id.matches(youtubeIDRegex)
@ -129,19 +133,17 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
logger.i { "No YouTube ID or URL found, performing search with track metadata" } logger.i { "No YouTube ID or URL found, performing search with track metadata" }
val isrcCode = track.isrcCode ?: isrcProvider.auto(track) 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" } logger.i { "Searching for video with query: $searchQuery" }
val videos = val videos = newPipeService.searchVideos(isrcCode ?: searchQuery)
newPipeService.searchVideos(isrcCode ?: searchQuery)
logger.i { "Found ${videos.size} videos with query: $searchQuery" } logger.i { "Found ${videos.size} videos with query: $searchQuery" }
var sources = videos.map { transformVideo(track, it) } var sources = videos.map { transformVideo(track, it) }
if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) { if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) {
logger.i { "ISRC search failed, fallback to title search" } logger.i { "ISRC search failed, fallback to title search" }
val videos = val videos = newPipeService.searchVideos(searchQuery)
newPipeService.searchVideos(searchQuery)
sources = videos.map { transformVideo(track, it) } sources = videos.map { transformVideo(track, it) }
} }

View File

@ -18,33 +18,15 @@
package dev.krtirtho.spotube.modules.album package dev.krtirtho.spotube.modules.album
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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 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.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes 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.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 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 @Composable
fun AlbumScreen( fun AlbumScreen(
@ -52,59 +34,15 @@ fun AlbumScreen(
viewModel: AlbumViewModel, viewModel: AlbumViewModel,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle() 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 currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) } val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
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 dataState = state as? AlbumScreenState.Data
val album = dataState?.album val album = dataState?.album
@ -142,40 +80,24 @@ fun AlbumScreen(
tracks = dataState?.tracks ?: emptyList(), tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null, hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore, isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = trackOptionsContext.currentTrackId,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playAlbumFromTrack, onTrackClick = viewModel::playAlbumFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage, onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction, onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState, trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = { tracks -> onBulkDownload = viewModel::downloadTracks,
downloadsViewModel.downloadTracks(tracks) onBulkAddToQueue = viewModel::addTracksToQueue,
}, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToQueue = { tracks -> onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false }, onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = { playlistId -> onPlaylistSelected = viewModel::addTracksToPlaylist,
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
) )
}, },
) )

View File

@ -26,19 +26,25 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository 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.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -89,6 +95,8 @@ class AlbumViewModel(
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<AlbumViewModel>() private val logger by injectLogger<AlbumViewModel>()
@ -97,12 +105,37 @@ class AlbumViewModel(
val savedAlbumIds val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow get() = libraryRepository.savedAlbumIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow() val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin repository.pluginManager.selectedMetadataPlugin
@ -111,6 +144,7 @@ class AlbumViewModel(
.distinctUntilChanged() .distinctUntilChanged()
.collect { .collect {
loadInitialData() loadInitialData()
loadCurrentUser()
} }
} }
blacklistRepository.blacklistedTracks blacklistRepository.blacklistedTracks
@ -121,6 +155,14 @@ class AlbumViewModel(
.launchIn(viewModelScope) .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 { private suspend fun loadInitialData() = runCatching {
_state.value = AlbumScreenState.Loading _state.value = AlbumScreenState.Loading
val albumInfo = repository.getAlbumInfo(albumId) val albumInfo = repository.getAlbumInfo(albumId)
@ -199,20 +241,10 @@ class AlbumViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val queueIndex = queue.indexOfFirst { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (queueIndex >= 0) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
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 -> { is TrackOptionsAction.AddToQueue -> {
@ -235,14 +267,50 @@ class AlbumViewModel(
} }
} }
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {
is TrackOptionsAction.AddToPlaylist -> {} val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
} }
} }
} }
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch { viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()

View File

@ -45,10 +45,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -63,14 +59,12 @@ import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist 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.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.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes 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.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton
@ -80,13 +74,9 @@ import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.TrackList 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.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree 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.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
@ -95,7 +85,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -103,58 +92,16 @@ fun ArtistScreen(
viewModel: ArtistViewModel, viewModel: ArtistViewModel,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val scope = rememberCoroutineScope()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) } val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
var currentUserId by remember { mutableStateOf<String?>(null) } val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
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( Scaffold(
topBar = { ApplicationMainBar() } topBar = { ApplicationMainBar() }
@ -209,8 +156,8 @@ fun ArtistScreen(
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playTopTracksFromTrack, onTrackClick = viewModel::playTopTracksFromTrack,
onTrackOptionsAction = ::handleTrackOptionsAction, onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState, trackOptionsState = trackOptionsContext::stateFor,
onArtistClick = { trackArtist -> onArtistClick = { trackArtist ->
navigationCommands.navigateTo( navigationCommands.navigateTo(
Routes.Artist( Routes.Artist(
@ -228,13 +175,10 @@ fun ArtistScreen(
onLoadNextPage = {}, onLoadNextPage = {},
simplified = true, simplified = true,
scrollable = false, scrollable = false,
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) }, onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) }, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = { tracks -> onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
) )
} }
@ -292,15 +236,8 @@ fun ArtistScreen(
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false }, onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = { playlistId -> onPlaylistSelected = viewModel::addTracksToPlaylist,
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
) )
} }
} }

View File

@ -27,15 +27,21 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger 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.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository 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.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -65,6 +71,8 @@ class ArtistViewModel(
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>() private val logger by injectLogger<ArtistViewModel>()
@ -77,12 +85,37 @@ class ArtistViewModel(
val savedTrackIds val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow get() = savedTracksRepository.savedTracksIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow() val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
libraryRepository.savedArtistIdsFlow.collect { libraryRepository.savedArtistIdsFlow.collect {
@ -98,9 +131,20 @@ class ArtistViewModel(
blacklistRepository.blacklistedArtists blacklistRepository.blacklistedArtists
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
.launchIn(viewModelScope) .launchIn(viewModelScope)
loadCurrentUser()
loadOverview() loadOverview()
} }
private fun loadCurrentUser() {
viewModelScope.launch {
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
}
}
fun refresh() { fun refresh() {
repository.invalidateCaches() repository.invalidateCaches()
loadOverview() loadOverview()
@ -285,22 +329,10 @@ class ArtistViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val queueIndex = queue.indexOfFirst { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (queueIndex >= 0) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
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 -> { is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
@ -319,14 +351,50 @@ class ArtistViewModel(
savedTracksRepository.saveTracks(listOf(track.id)) savedTracksRepository.saveTracks(listOf(track.id))
} }
} }
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {
is TrackOptionsAction.AddToPlaylist -> {} val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
} }
} }
} }
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
}
private fun loadOverview() { private fun loadOverview() {
_state.value = ArtistScreenState.Loading _state.value = ArtistScreenState.Loading
viewModelScope.launch { viewModelScope.launch {

View File

@ -27,35 +27,22 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.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.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes 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.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.CollectionView 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.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare 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 @Composable
fun PlaylistScreen( fun PlaylistScreen(
@ -63,40 +50,17 @@ fun PlaylistScreen(
viewModel: PlaylistViewModel, viewModel: PlaylistViewModel,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle() 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 currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.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 showEditPlaylist by remember { mutableStateOf(false) }
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var showAddTracksDialog 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 dataState = state as? PlaylistScreenState.Data
val playlist = dataState?.playlist val playlist = dataState?.playlist
@ -108,23 +72,6 @@ fun PlaylistScreen(
val isPlaying = currentCollectionEntry?.id == playlistId && val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING 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 errorMessage = (state as? PlaylistScreenState.Error)?.message
val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
@ -168,27 +115,18 @@ fun PlaylistScreen(
tracks = dataState?.tracks ?: emptyList(), tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null, hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore, isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = trackOptionsContext.currentTrackId,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playPlaylistFromTrack, onTrackClick = viewModel::playPlaylistFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage, onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction, onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState, trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = { tracks -> onBulkDownload = viewModel::downloadTracks,
downloadsViewModel.downloadTracks(tracks) onBulkAddToQueue = viewModel::addTracksToQueue,
}, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToQueue = { tracks -> onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
footerContent = footerContent, footerContent = footerContent,
trailingContent = { trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
@ -219,15 +157,8 @@ fun PlaylistScreen(
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false }, onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = { selectedPlaylistId -> onPlaylistSelected = viewModel::addTracksToPlaylist,
scope.launch {
libraryRepository.addTracksToPlaylist(
selectedPlaylistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
) )
AddTracksToPlaylistDialog( AddTracksToPlaylistDialog(

View File

@ -26,20 +26,26 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository 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.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -90,6 +96,8 @@ class PlaylistViewModel(
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<PlaylistViewModel>() private val logger by injectLogger<PlaylistViewModel>()
@ -107,6 +115,28 @@ class PlaylistViewModel(
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin repository.pluginManager.selectedMetadataPlugin
@ -244,20 +274,10 @@ class PlaylistViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val queueIndex = queue.indexOfFirst { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (queueIndex >= 0) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
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 -> { is TrackOptionsAction.AddToQueue -> {
@ -280,14 +300,50 @@ class PlaylistViewModel(
} }
} }
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {
is TrackOptionsAction.AddToPlaylist -> {} val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
} }
} }
} }
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean { fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = _blacklistedTrackIds.value val trackIds = _blacklistedTrackIds.value
val artistIds = _blacklistedArtistIds.value val artistIds = _blacklistedArtistIds.value

View File

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

View File

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

View File

@ -18,34 +18,16 @@
package dev.krtirtho.spotube.modules.saved_tracks package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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 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.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes 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.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.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.Res
import spotube.composeapp.generated.resources.liked_tracks import spotube.composeapp.generated.resources.liked_tracks
@ -54,56 +36,14 @@ fun SavedTracksScreen(
viewModel: SavedTracksViewModel, viewModel: SavedTracksViewModel,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle() 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 currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) } val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
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 dataState = state as? SavedTracksScreenState.Data
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks && val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
@ -133,40 +73,24 @@ fun SavedTracksScreen(
tracks = dataState?.tracks ?: emptyList(), tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null, hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore, isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = trackOptionsContext.currentTrackId,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playSavedTracksFromTrack, onTrackClick = viewModel::playSavedTracksFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage, onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction, onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState, trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = { tracks -> onBulkDownload = viewModel::downloadTracks,
downloadsViewModel.downloadTracks(tracks) onBulkAddToQueue = viewModel::addTracksToQueue,
}, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToQueue = { tracks -> onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false }, onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = { playlistId -> onPlaylistSelected = viewModel::addTracksToPlaylist,
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
) )
}, },
) )

View File

@ -34,10 +34,15 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -46,6 +51,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -88,6 +94,9 @@ class SavedTracksViewModel(
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val libraryRepository: LibraryRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<SavedTracksViewModel>() private val logger by injectLogger<SavedTracksViewModel>()
@ -96,12 +105,36 @@ class SavedTracksViewModel(
val savedTrackIdsFlow: StateFlow<Set<String>> val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow get() = repository.savedTracksIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow() val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
combine( combine(
@ -112,6 +145,7 @@ class SavedTracksViewModel(
repository.savedTracksIdsFlow repository.savedTracksIdsFlow
) { _, _ -> }.collect { ) { _, _ -> }.collect {
loadInitialData() loadInitialData()
loadCurrentUser()
} }
} }
blacklistRepository.blacklistedTracks blacklistRepository.blacklistedTracks
@ -122,6 +156,14 @@ class SavedTracksViewModel(
.launchIn(viewModelScope) .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() { private suspend fun loadInitialData() {
runCatching { runCatching {
val tracksResult = repository.getSavedTracks() val tracksResult = repository.getSavedTracks()
@ -182,20 +224,10 @@ class SavedTracksViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val queueIndex = queue.indexOfFirst { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (queueIndex >= 0) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
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 -> { is TrackOptionsAction.AddToQueue -> {
@ -210,14 +242,50 @@ class SavedTracksViewModel(
} }
is TrackOptionsAction.ToggleFavorite -> {} is TrackOptionsAction.ToggleFavorite -> {}
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {
is TrackOptionsAction.AddToPlaylist -> {} val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
} }
} }
} }
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean { fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = _blacklistedTrackIds.value val trackIds = _blacklistedTrackIds.value
val artistIds = _blacklistedArtistIds.value val artistIds = _blacklistedArtistIds.value

View File

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

View File

@ -20,6 +20,10 @@ package dev.krtirtho.spotube.modules.settings
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import dev.krtirtho.spotube.core.db.Database 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.coroutines.flow.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -28,14 +32,20 @@ class SettingsRepository(private val database: Database) {
private val SETTINGS_KEY = stringPreferencesKey("user_settings") private val SETTINGS_KEY = stringPreferencesKey("user_settings")
} }
val userSettings: Flow<UserSettings> = database.settingsDataStore.data.map { prefs -> val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY] val json = prefs[SETTINGS_KEY]
if (json != null) { if (json != null) {
Json.decodeFromString<UserSettings>(json as String) Json.decodeFromString<UserSettings>(json as String)
} else { } else {
UserSettings() // Default value UserSettings() // Default value
} }
} }.stateIn(
scope,
started = SharingStarted.Eagerly,
initialValue = UserSettings() // Default value
)
suspend fun updateSettings(newSettings: UserSettings) { suspend fun updateSettings(newSettings: UserSettings) {
database.settingsDataStore.edit { prefs -> database.settingsDataStore.edit { prefs ->

View File

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

View File

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

View File

@ -19,9 +19,6 @@ package dev.krtirtho.spotube.modules.shell.player_queue
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -29,7 +26,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -44,127 +40,53 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.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.Card
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme 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.TextField
import dev.krtirtho.spotube.core.ui.base.copyShape 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState 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 @Composable
fun PlayerQueueContent( fun PlayerQueueContent(
viewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(), viewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val logger = rememberLogger("PlayerQueueContent") val state by viewModel.queueContentUiState.collectAsState()
val queueContentUiState by viewModel.queueContentUiState.collectAsState() val displayItems = state.displayItems
val queue = queueContentUiState.queue val filterQuery = state.filterQuery
val currentQueueEntry = queueContentUiState.currentQueueEntry val isFiltered = state.isFiltered
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 lazyListState = rememberLazyListState()
val reorderableLazyListState = val reorderableLazyListState = rememberReorderableLazyListState(
rememberReorderableLazyListState( lazyListState,
lazyListState, onMove = { from, to ->
onMove = { from, to -> if (isFiltered) return@rememberReorderableLazyListState
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) { Surface(modifier = modifier) {
Column( Column(
@ -204,7 +126,7 @@ fun PlayerQueueContent(
} }
} }
if (displayList.isEmpty()) { if (displayItems.isEmpty()) {
Text( Text(
text = "No queue entries", text = "No queue entries",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -217,7 +139,7 @@ fun PlayerQueueContent(
state = lazyListState, state = lazyListState,
contentPadding = PaddingValues(bottom = 8.dp), contentPadding = PaddingValues(bottom = 8.dp),
) { ) {
items(displayList, key = { item -> item.id }) { item -> items(displayItems, key = { it.id }) { item ->
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
QueueItemRow( QueueItemRow(
@ -225,7 +147,8 @@ fun PlayerQueueContent(
reorderScope = if (isFiltered) null else this, reorderScope = if (isFiltered) null else this,
onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
onDragStopped = ::finalizeReorder, onDragStarted = { viewModel.onDragStart() },
onDragStopped = { viewModel.onDragStop() },
) )
} }
} }
@ -242,6 +165,7 @@ private fun QueueItemRow(
reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?, reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onRemoveClick: () -> Unit, onRemoveClick: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit, onDragStopped: () -> Unit,
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@ -263,7 +187,12 @@ private fun QueueItemRow(
.size(24.dp) .size(24.dp)
.then( .then(
if (reorderScope != null) { if (reorderScope != null) {
with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) } with(reorderScope) {
Modifier.draggableHandle(
onDragStarted = { onDragStarted() },
onDragStopped = onDragStopped,
)
}
} else { } else {
Modifier Modifier
}, },
@ -319,7 +248,7 @@ private fun QueueItemRow(
}, },
trailing = { trailing = {
Text( Text(
text = item.durationMs.toDurationString(), text = item.durationLabel,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -356,53 +285,3 @@ private fun QueueItemRow(
} }
) )
} }
private fun QueueEntry.toQueueDisplayData(): Tuple4<String, String, Long, String?> {
return when (this) {
is QueueEntry.StreamingTrack -> {
val imageUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
Tuple4(
track.title,
track.artists.joinToString(", ") { it.name },
track.durationMs,
imageUrl,
)
}
is QueueEntry.LocalTrack -> Tuple4(
name,
artists.joinToString(", "),
duration,
null,
)
}
}
private data class Tuple4<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
return when {
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
this.track.id == current.track.id
}
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
this.url == current.url && this.name == current.name
}
else -> false
}
}
private fun Long.toDurationString(): String {
val totalSeconds = (this / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}

View File

@ -30,10 +30,20 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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( data class QueueContentUiState(
val filterQuery: String = "", val filterQuery: String = "",
val queue: List<QueueEntry> = emptyList(), val displayItems: List<QueueItemUi> = emptyList(),
val currentQueueEntry: QueueEntry? = null, val isFiltered: Boolean = false,
) )
class PlayerQueueContentViewModel( class PlayerQueueContentViewModel(
@ -41,18 +51,79 @@ class PlayerQueueContentViewModel(
) : ViewModel() { ) : ViewModel() {
private val queueVisibilityFlow = MutableStateFlow(false) private val queueVisibilityFlow = MutableStateFlow(false)
private val queueFilterFlow = MutableStateFlow("") private val queueFilterFlow = MutableStateFlow("")
private val reorderBuffer = MutableStateFlow<List<QueueItemUi>?>(null)
private var moveFromOriginal: Int? = null
private var moveToDisplay: Int? = null
val isQueueVisible: StateFlow<Boolean> = queueVisibilityFlow.asStateFlow() val isQueueVisible: StateFlow<Boolean> = queueVisibilityFlow.asStateFlow()
val queueContentUiState: StateFlow<QueueContentUiState> = combine( private val computedItems: StateFlow<List<QueueItemUi>> = combine(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, 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, queueFilterFlow,
) { queue, currentQueueEntry, filterQuery -> ) { items, buffer, filterQuery ->
val normalizedFilter = filterQuery.trim().lowercase()
val isFiltered = normalizedFilter.isNotBlank()
val filtered = if (isFiltered) {
items.filter { item ->
item.title.lowercase().contains(normalizedFilter) ||
item.subtitle.lowercase().contains(normalizedFilter)
}
} else {
items
}
QueueContentUiState( QueueContentUiState(
filterQuery = filterQuery, filterQuery = filterQuery,
queue = queue, displayItems = buffer ?: filtered,
currentQueueEntry = currentQueueEntry, isFiltered = isFiltered,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -72,13 +143,6 @@ class PlayerQueueContentViewModel(
queueFilterFlow.value = query 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) { fun playQueueItem(index: Int) {
if (index < 0) return if (index < 0) return
viewModelScope.launch { viewModelScope.launch {
@ -96,9 +160,67 @@ class PlayerQueueContentViewModel(
} }
} }
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
viewModelScope.launch {
audioPlayerQueue.move(fromIndex, toIndex)
}
}
fun clearQueue() { fun clearQueue() {
viewModelScope.launch { viewModelScope.launch {
audioPlayerQueue.clear() 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 package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState import dev.nucleusframework.webview.web.WebViewState
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSHTTPCookieStorage import platform.Foundation.NSHTTPCookieStorage

View File

@ -490,6 +490,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (disposed || index !in currentPlaylist.indices) return if (disposed || index !in currentPlaylist.indices) return
currentIndex = index currentIndex = index
_currentMediaItem.tryEmit(currentPlaylist[index]) _currentMediaItem.tryEmit(currentPlaylist[index])
pendingNextIndex = index
mediaListPlayer.controls().play(index) mediaListPlayer.controls().play(index)
} }
} }

View File

@ -19,13 +19,16 @@ package dev.krtirtho.spotube.core.di
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.mediacontrol.SystemMediaControlService
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.share.JvmShareService import dev.krtirtho.spotube.core.share.JvmShareService
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.systemtray.SystemTrayService import dev.krtirtho.spotube.core.systemtray.SystemTrayService
import dev.krtirtho.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService import dev.krtirtho.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService
import org.koin.core.module.dsl.createdAtStart
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.withOptions
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModules = module { actual val platformModules = module {
@ -33,5 +36,6 @@ actual val platformModules = module {
single<AudioPlayerInterface> { AudioPlayer(Unit) } single<AudioPlayerInterface> { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() } single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
single<ShareService> { JvmShareService() } single<ShareService> { JvmShareService() }
single { SystemTrayService(get(), get(), get(), get()) } single { SystemTrayService(get(), get(), get()) }
single { SystemMediaControlService(get()) } withOptions { createdAtStart() }
} }

View File

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

View File

@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.systemtray
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
import dev.nucleusframework.application.NucleusApplicationScope
import dev.nucleusframework.composenativetray.tray.api.Tray
import org.koin.compose.koinInject
/**
* Renders the system tray icon + menu via Compose Native Tray (no AWT/Swing).
*
* Must be composed inside `nucleusApplication { }` and kept in composition
* while the tray should be visible (the menu is fully reactive via
* [SystemTrayService.state]).
*/
@Composable
fun NucleusApplicationScope.SystemTray(
isWindowVisible: Boolean,
onToggleWindowVisibility: () -> Unit,
onExit: () -> Unit,
) {
val trayService = koinInject<SystemTrayService>()
val state by trayService.state.collectAsState()
Tray(
icon = Iconsax.IconsaxMusic,
tooltip = "Spotube",
primaryAction = onToggleWindowVisibility,
) {
Item(label = if (isWindowVisible) "Hide Window" else "Show Window") {
onToggleWindowVisibility()
}
Divider()
Item(label = if (state.isPlaying) "Pause" else "Play") {
trayService.togglePlayPause()
}
Item(label = "Next Track") { trayService.skipToNext() }
Item(label = "Previous Track") { trayService.skipToPrevious() }
Divider()
CheckableItem(
label = "Shuffle",
checked = state.shuffleEnabled,
onCheckedChange = trayService::setShuffle,
)
SubMenu(label = "Loop") {
Item(label = "Loop: OFF") { trayService.setLoop(LoopState.NONE) }
Item(label = "Loop: ONE") { trayService.setLoop(LoopState.ONE) }
Item(label = "Loop: ALL") { trayService.setLoop(LoopState.ALL) }
}
Divider()
Item(label = "Volume: ${(state.volume * 100).toInt()}%")
Item(label = "Volume +") { trayService.increaseVolume() }
Item(label = "Volume -") { trayService.decreaseVolume() }
Item(label = if (state.volume <= 0f) "Unmute" else "Mute") { trayService.toggleMute() }
Divider()
if (state.currentTrackId != null) {
Item(label = if (state.isCurrentTrackLiked) "Unlike Track" else "Like Track") {
trayService.toggleLike()
}
}
Divider()
Item(label = "Exit") { onExit() }
}
}

View File

@ -20,421 +20,143 @@ package dev.krtirtho.spotube.core.systemtray
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState 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.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob 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.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch 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( class SystemTrayService(
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val settingsProvider: SettingsProvider,
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
) : KoinComponent, AutoCloseable { ) : AutoCloseable {
private val logger by injectLogger<SystemTrayService>()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var trayIcon: TrayIcon? = null private val volumeStep = 0.05f
private var menuDialog: TrayMenuDialog? = null
private var windowVisible: Boolean = true
private var onToggleWindowVisibility: () -> Unit = {} private var onToggleWindowVisibility: () -> Unit = {}
private var onExit: () -> Unit = {}
private val volumeStep = 0.05f val state: StateFlow<TrayState> =
private var trayEnabled = false combine(
audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow,
audioPlayer.loopStateFlow,
audioPlayer.shuffleModeFlow,
audioPlayer.volumeFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
) { values ->
TrayState(
playerState = values[0] as PlayerState,
mediaItem = values[1] as MediaItem?,
loopState = values[2] as LoopState,
shuffleEnabled = values[3] as Boolean,
volume = values[4] as Float,
currentEntry = values[5] as QueueEntry?,
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
)
}.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial)
@Volatile fun setCallbacks(onToggleWindowVisibility: () -> Unit) {
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,
audioPlayer.loopStateFlow,
audioPlayer.shuffleModeFlow,
audioPlayer.volumeFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
) { values ->
TrayState(
playerState = values[0] as PlayerState,
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>),
)
}.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,
onExit: () -> Unit,
) {
this.onToggleWindowVisibility = onToggleWindowVisibility this.onToggleWindowVisibility = onToggleWindowVisibility
this.onExit = onExit
} }
fun hideWindow() { fun hideWindow() = onToggleWindowVisibility()
if (windowVisible) {
windowVisible = false fun togglePlayPause() {
onToggleWindowVisibility() scope.launch {
if (state.value.playerState == PlayerState.PLAYING) audioPlayer.pause() else audioPlayer.play()
} }
} }
fun showWindow() { fun skipToNext() {
if (!windowVisible) { scope.launch { audioPlayer.skipToNext() }
windowVisible = true
onToggleWindowVisibility()
}
} }
private fun ensureTrayCreated() { fun skipToPrevious() {
if (trayIcon != null) return scope.launch { audioPlayer.skipToPrevious() }
if (!SystemTray.isSupported()) { }
logger.w { "System tray is not supported on this platform" }
return
}
SwingUtilities.invokeLater { fun setShuffle(enabled: Boolean) {
try { scope.launch { audioPlayer.shuffle(enabled) }
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) fun setLoop(loopState: LoopState) {
trayIcon = icon scope.launch { audioPlayer.loop(loopState) }
logger.i { "System tray created" } }
} catch (e: Exception) {
logger.e(e) { "Failed to create system tray" } 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() { override fun close() {
removeTray() scope.cancel()
} }
private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) { data class TrayState(
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 playerState: PlayerState,
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, val mediaItem: MediaItem?,
val loopState: LoopState, val loopState: LoopState,
val shuffleEnabled: Boolean, val shuffleEnabled: Boolean,
val volume: Float, val volume: Float,
val currentEntry: QueueEntry?, val currentEntry: QueueEntry?,
val savedTrackIds: Set<String>, 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,6 +22,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.foundation.window.WindowDraggableArea
@ -44,6 +45,10 @@ import dev.krtirtho.spotube.resources.iconsax.FluentMaximize
import dev.krtirtho.spotube.resources.iconsax.FluentMinus import dev.krtirtho.spotube.resources.iconsax.FluentMinus
import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple
import dev.krtirtho.spotube.resources.iconsax.Iconsax 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 import org.koin.compose.koinInject
@ -55,27 +60,27 @@ actual fun ApplicationMainBar(
actions: @Composable (RowScope.() -> Unit), actions: @Composable (RowScope.() -> Unit),
backButton: Boolean, backButton: Boolean,
) { ) {
LocalWindowScope.current.WindowDraggableArea { Box(
Box( modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth() .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize)
) { ) {
TopAppBar( TopAppBar(
title = title, title = title,
actions = { actions = {
actions() actions()
}, },
navigationIcon = { navigationIcon = {
if (backButton) { if (backButton) {
ApplicationBackButton() ApplicationBackButton()
} }
}, },
subtitle = subtitle, subtitle = subtitle,
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
) )
WindowButtons( LocalWindowScope.current.WindowControls(
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),
) renderer = WindowControlsRenderer.Platform, // default
} )
} }
} }

View File

@ -19,7 +19,8 @@ package dev.krtirtho.spotube.core.ui.component
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.ApplicationScope
import dev.nucleusframework.application.NucleusApplicationScope
val LocalApplicationScope = staticCompositionLocalOf<ApplicationScope> { val LocalApplicationScope = staticCompositionLocalOf< NucleusApplicationScope> {
error("No ApplicationScope found in LocalApplicationScope") error("No ApplicationScope found in LocalApplicationScope")
} }

View File

@ -18,11 +18,11 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowState 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. // LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy.
val LocalWindowScope = compositionLocalOf<FrameWindowScope> { val LocalWindowScope = compositionLocalOf<NucleusDecoratedWindowScope> {
error("No Window found in LocalWindowScope") error("No Window found in LocalWindowScope")
} }
val LocalWindowState = compositionLocalOf<WindowState> { val LocalWindowState = compositionLocalOf<WindowState> {

View File

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

View File

@ -25,18 +25,21 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.unit.dp 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 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.di.initKoin
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths 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.systemtray.SystemTrayService
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowState import dev.krtirtho.spotube.core.ui.component.LocalWindowState
import dev.krtirtho.spotube.modules.settings.SettingsProvider 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 io.github.vinceglb.filekit.FileKit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -54,14 +57,39 @@ private object KoinServicesProvider : KoinComponent {
val settingsProvider: SettingsProvider get() = get() 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) @OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main() {
disableWebKitGpuCompositing()
FileKit.init(appId = "dev.krtirtho.spotube") FileKit.init(appId = "dev.krtirtho.spotube")
initKoin() initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths) NewPipeDownloader.init(KoinPathsProvider.paths)
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
application { nucleusApplication(backend = NucleusBackend.Tao) {
val windowState = rememberWindowState( val windowState = rememberWindowState(
width = 1080.dp, width = 1080.dp,
height = 720.dp height = 720.dp
@ -75,16 +103,24 @@ fun main() {
var isWindowVisible by remember { mutableStateOf(true) } var isWindowVisible by remember { mutableStateOf(true) }
trayService.setCallbacks( val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }
onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }, val onExit = {
onExit = { trayService.close()
trayService.close() appScope.cancel()
appScope.cancel() exitApplication()
exitApplication() }
}
)
Window( trayService.setCallbacks(onToggleWindowVisibility)
if (minimizeToTray) {
SystemTray(
isWindowVisible = isWindowVisible,
onToggleWindowVisibility = onToggleWindowVisibility,
onExit = onExit,
)
}
DecoratedWindow(
state = windowState, state = windowState,
onCloseRequest = { onCloseRequest = {
if (minimizeToTray) { if (minimizeToTray) {
@ -96,12 +132,11 @@ fun main() {
} }
}, },
title = "Spotube", title = "Spotube",
decoration = WindowDecoration.Undecorated(),
visible = isWindowVisible, visible = isWindowVisible,
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalApplicationScope provides this@application, LocalApplicationScope provides this@nucleusApplication,
LocalWindowScope provides this@Window, LocalWindowScope provides this@DecoratedWindow,
LocalWindowState provides windowState LocalWindowState provides windowState
) { ) {
App() App()

View File

@ -20,55 +20,56 @@ android-minSdk = "28"
android-targetSdk = "36" android-targetSdk = "36"
androidx-activity = "1.13.0" androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core = "1.18.0" androidx-core = "1.19.0"
androidx-espresso = "3.7.0" androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.11.0"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
appdirs = "1.5.0" appdirs = "1.5.0"
cache4k = "0.14.0" cache4k = "0.14.0"
carApp = "1.7.0" carApp = "1.7.0"
composeHotReload = "1.1.0" composeHotReload = "1.2.0"
composeMultiplatform = "1.10.3" composeMultiplatform = "1.11.1"
coil = "3.4.0" coil = "3.5.0"
composePlaceholderMaterial3 = "1.0.12" composePlaceholderMaterial3 = "1.0.12"
composeShimmer = "1.5.0-beta02" composeShimmer = "1.5.0"
desugar_jdk_libs = "2.1.5" desugar_jdk_libs = "2.1.5"
haze = "2.0.0-alpha01" haze = "2.0.0-alpha03"
jna = "5.18.1" jna = "5.18.1"
junit = "4.13.2" junit = "4.13.2"
kermit = "2.1.0" kermit = "2.1.0"
kmpZip = "0.10.0" kmpZip = "0.13.0"
kotlin = "2.3.21" kotlin = "2.3.21"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.0" kotlinx-io = "0.9.1"
material3 = "1.10.0-alpha05" material3 = "1.10.0-alpha05"
kotlinx-serialization-json = "1.11.0" kotlinx-serialization-json = "1.11.0"
materialKolor = "4.1.1" materialKolor = "4.1.1"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.2" newpipeextractor = "v0.26.2"
newpipeExtractorKmp = "1.2.1" newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.6"
semver = "2.1.0" semver = "2.1.0"
vlcj = "4.12.1" vlcj = "4.12.1"
vlcjNative = "4.12.0" vlcjNative = "4.12.0"
ziplineVersion = "1.27.0" ziplineVersion = "1.27.0"
ktor = "3.4.3" ktor = "3.5.1"
jetbrainsKotlinJvm = "2.3.21" kotlinStdlib = "2.4.10"
kotlinStdlib = "2.3.21"
runner = "1.7.0" runner = "1.7.0"
core = "1.7.0" core = "1.7.0"
filekit = "0.13.0" filekit = "0.14.2"
compose-webview = "0.1.0-SNAPSHOT" compose-webview = "1.0.1"
koin = "4.2.1" composeNativeTray = "2.0.3"
multiplatform-nav3-ui = "1.0.0-alpha06" koin = "4.2.2"
compose-multiplatform-adaptive = "1.3.0-alpha02" multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-lifecycle = "2.10.0-alpha05" compose-multiplatform-adaptive = "1.3.0-beta02"
compose-multiplatform-lifecycle = "2.11.0"
feather-icons = "1.1.1" feather-icons = "1.1.1"
material3-window-size = "1.9.0" material3-window-size = "1.9.0"
kotlinx-datetime = "0.7.1" kotlinx-datetime = "0.8.0"
media3 = "1.8.0" media3 = "1.10.1"
androidx-datastore = "1.2.1" androidx-datastore = "1.2.1"
ksp = "2.3.5" ksp = "2.3.5"
spotube-gradle = "0.1.0" spotube-gradle = "0.1.1"
cryptography = "0.6.0" cryptography = "0.6.0"
reorderable = "3.1.0" reorderable = "3.1.0"
vlcjBundler = "0.1.0" vlcjBundler = "0.1.0"
@ -120,6 +121,10 @@ material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "m
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-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" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }
vlcj-natives = { module = "uk.co.caprica:vlcj-natives", version.ref = "vlcjNative" } vlcj-natives = { module = "uk.co.caprica:vlcj-natives", version.ref = "vlcjNative" }
@ -141,7 +146,8 @@ androidx-core = { group = "androidx.test", name = "core", version.ref = "core" }
filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" } filekit-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 = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" } filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" }
compose-webview = { module = "io.github.kdroidfilter:composewebview", version.ref = "compose-webview" } compose-webview = { module = "dev.nucleusframework:composewebview", version.ref = "compose-webview" }
compose-native-tray = { module = "dev.nucleusframework:composenativetray", version.ref = "composeNativeTray" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", 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" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
@ -173,7 +179,7 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" } zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
mavenPublish = { id = "maven-publish" } mavenPublish = { id = "maven-publish" }
@ -184,3 +190,4 @@ uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

0
gradlew vendored Normal file → Executable file
View File

View File

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