Compare commits

...

6 Commits

22 changed files with 448 additions and 559 deletions

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/`.

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,11 @@ 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)
} }
} }
} }
@ -280,8 +286,7 @@ 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(
@ -298,6 +303,8 @@ compose.desktop {
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0" packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
licenseFile = project.file("../LICENSE") licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
targetFormats( targetFormats(
TargetFormat.Dmg, TargetFormat.Dmg,
TargetFormat.Deb, TargetFormat.Deb,
@ -309,8 +316,12 @@ compose.desktop {
modules("jdk.unsupported") modules("jdk.unsupported")
homepage = "https://spotube.cc"
linux { linux {
modules("jdk.security.auth") modules("jdk.security.auth")
debMaintainer = "Team Spotube <team.spotube@proton.me>"
rpmLicenseType = "AGPL-3.0-or-later"
} }
windows { windows {
shortcut = true shortcut = true
@ -329,7 +340,6 @@ compose.desktop {
buildTypes.release.proguard { buildTypes.release.proguard {
configurationFiles.from(project.file("proguard-rules.pro")) configurationFiles.from(project.file("proguard-rules.pro"))
} }
}
} }
// Issue with compose plugin // Issue with compose plugin

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,6 +114,10 @@ fun App(
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
val baseUITheme = rememberBaseUITheme() val baseUITheme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) { CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
Box(modifier = Modifier.fillMaxSize()) {
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
Box(Modifier.fillMaxSize()) {
AppShell(navigator, navigationState) { AppShell(navigator, navigationState) {
SharedTransitionLayout { SharedTransitionLayout {
CompositionLocalProvider( CompositionLocalProvider(
@ -123,6 +133,12 @@ fun App(
} }
} }
} }
when (currentRoute) {
Routes.WebView -> WebViewScreen(koinInject())
else -> {}
}
}
}
content() content()
} }
} }

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
@ -97,9 +95,7 @@ val navigationModule = module {
navigation<Routes.Plugins> { navigation<Routes.Plugins> {
PluginScreen(pluginManager = get()) PluginScreen(pluginManager = get())
} }
navigation<Routes.WebView> { navigation<Routes.WebView> {}
WebViewScreen(get())
}
navigation<Routes.Playlist> { navigation<Routes.Playlist> {
PlaylistScreen( PlaylistScreen(
playlistId = it.playlistId, playlistId = it.playlistId,

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,6 +151,8 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
topBar = { topBar = {
ApplicationMainBar(
title = {
Row( Row(
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@ -199,14 +203,11 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
.wrapContentHeight(Alignment.CenterVertically) .wrapContentHeight(Alignment.CenterVertically)
) )
} }
IconButton(
onClick = {
webViewController.closeWebview()
}) {
Icon(FeatherIcons.X, contentDescription = "Close WebView")
} }
} }
}) { innerPadding -> )
})
{ innerPadding ->
WebView( WebView(
state = state, state = state,
modifier = Modifier.padding(innerPadding).fillMaxSize(), modifier = Modifier.padding(innerPadding).fillMaxSize(),

View File

@ -676,7 +676,7 @@ fun PluginScreen(
val service = val service =
selectedService ?: return@LaunchedEffect selectedService ?: return@LaunchedEffect
try {
service.use { service.use {
val pluginRequiresAuth = val pluginRequiresAuth =
coreAPI.requiresAuthentication coreAPI.requiresAuthentication
@ -687,6 +687,10 @@ fun PluginScreen(
isLoggedIn = loggedIn isLoggedIn = loggedIn
} }
} }
} catch (_: Exception) {
// Service may have been stopped/closed
// concurrently when the plugin selection changed
}
} }
val logoPath = remember(plugin.id) { val logoPath = remember(plugin.id) {

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

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

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,81 +20,40 @@ 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
@Volatile
private var currentState: TrayState? = null
init {
scope.launch {
settingsProvider.settingsState
.map { it?.minimizeToTray ?: false }
.distinctUntilChanged()
.collect { enabled ->
trayEnabled = enabled
if (enabled) {
ensureTrayCreated()
} else {
removeTray()
}
}
}
scope.launch {
combine( combine(
audioPlayer.playerStateFlow, audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow, audioPlayer.currentMediaItemFlow,
@ -106,335 +65,98 @@ class SystemTrayService(
) { values -> ) { values ->
TrayState( TrayState(
playerState = values[0] as PlayerState, playerState = values[0] as PlayerState,
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?, mediaItem = values[1] as MediaItem?,
loopState = values[2] as LoopState, loopState = values[2] as LoopState,
shuffleEnabled = values[3] as Boolean, shuffleEnabled = values[3] as Boolean,
volume = values[4] as Float, volume = values[4] as Float,
currentEntry = values[5] as QueueEntry?, currentEntry = values[5] as QueueEntry?,
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>), savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
) )
}.collect { state -> }.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial)
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( fun setCallbacks(onToggleWindowVisibility: () -> Unit) {
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)
trayIcon = icon
logger.i { "System tray created" }
} catch (e: Exception) {
logger.e(e) { "Failed to create system tray" }
}
}
} }
private fun removeTray() { fun setLoop(loopState: LoopState) {
menuDialog?.dispose() scope.launch { audioPlayer.loop(loopState) }
menuDialog = null }
trayIcon?.let {
try { fun increaseVolume() {
SystemTray.getSystemTray().remove(it) scope.launch { audioPlayer.setVolume((state.value.volume + volumeStep).coerceAtMost(1f)) }
} catch (e: Exception) { }
logger.e(e) { "Failed to remove tray icon" }
fun decreaseVolume() {
scope.launch { audioPlayer.setVolume((state.value.volume - volumeStep).coerceAtLeast(0f)) }
}
fun toggleMute() {
scope.launch { audioPlayer.setVolume(if (state.value.volume <= 0f) 0.5f else 0f) }
}
fun toggleLike() {
scope.launch {
val trackId = (state.value.currentEntry as? QueueEntry.StreamingTrack)?.track?.id ?: return@launch
val isLiked = state.value.savedTrackIds.contains(trackId)
if (isLiked) {
savedTracksRepository.removeSavedTracks(listOf(trackId))
} else {
savedTracksRepository.saveTracks(listOf(trackId))
} }
} }
trayIcon = null
} }
override fun close() { 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,9 +60,9 @@ 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,
@ -72,11 +77,11 @@ actual fun ApplicationMainBar(
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
) )
} }
}
} }
@Composable @Composable

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

@ -47,16 +47,18 @@ materialKolor = "4.1.1"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.2" newpipeextractor = "v0.26.2"
newpipeExtractorKmp = "1.3.0" newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.4"
semver = "2.1.0" semver = "2.1.0"
vlcj = "4.12.1" vlcj = "4.12.1"
vlcjNative = "4.12.0" vlcjNative = "4.12.0"
ziplineVersion = "1.27.0" ziplineVersion = "1.27.0"
ktor = "3.5.1" ktor = "3.5.1"
kotlinStdlib = "2.3.21" kotlinStdlib = "2.4.10"
runner = "1.7.0" runner = "1.7.0"
core = "1.7.0" core = "1.7.0"
filekit = "0.14.2" filekit = "0.14.2"
compose-webview = "0.1.0-SNAPSHOT" compose-webview = "1.0.1"
composeNativeTray = "2.0.3"
koin = "4.2.2" koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1" multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-beta02" compose-multiplatform-adaptive = "1.3.0-beta02"
@ -119,6 +121,9 @@ 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-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" }
@ -140,7 +145,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" }
@ -183,3 +189,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

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()
} }
} }