mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 14:44:00 +00:00
Compare commits
No commits in common. "11cc5e24574a92af81ea45e36bb1ad22678a9d66" and "d95e1aad706027bece02703afac9216a2d3fad0b" have entirely different histories.
11cc5e2457
...
d95e1aad70
@ -20,7 +20,6 @@
|
|||||||
## 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/`.
|
||||||
|
|||||||
@ -33,5 +33,4 @@ 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
|
|
||||||
}
|
}
|
||||||
@ -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 dev.nucleusframework.desktop.application.dsl.TargetFormat
|
import org.jetbrains.compose.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,7 +35,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,11 +220,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -286,59 +280,55 @@ dependencies {
|
|||||||
debugImplementation(libs.compose.uiTooling)
|
debugImplementation(libs.compose.uiTooling)
|
||||||
}
|
}
|
||||||
|
|
||||||
nucleus.application {
|
compose.desktop {
|
||||||
mainClass = "dev.krtirtho.spotube.MainKt"
|
application {
|
||||||
|
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
modules("jdk.unsupported")
|
nativeDistributions {
|
||||||
|
packageName = "dev.krtirtho.spotube"
|
||||||
|
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
|
||||||
|
licenseFile = project.file("../LICENSE")
|
||||||
|
|
||||||
homepage = "https://spotube.cc"
|
targetFormats(
|
||||||
|
TargetFormat.Dmg,
|
||||||
|
TargetFormat.Deb,
|
||||||
|
TargetFormat.Rpm,
|
||||||
|
TargetFormat.AppImage,
|
||||||
|
TargetFormat.Msi,
|
||||||
|
TargetFormat.Exe,
|
||||||
|
)
|
||||||
|
|
||||||
linux {
|
modules("jdk.unsupported")
|
||||||
modules("jdk.security.auth")
|
|
||||||
debMaintainer = "Team Spotube <team.spotube@proton.me>"
|
|
||||||
rpmLicenseType = "AGPL-3.0-or-later"
|
|
||||||
}
|
|
||||||
windows {
|
|
||||||
shortcut = true
|
|
||||||
menu = true
|
|
||||||
dirChooser = true
|
|
||||||
perUserInstall = true
|
|
||||||
}
|
|
||||||
|
|
||||||
macOS {
|
linux {
|
||||||
notarization {
|
modules("jdk.security.auth")
|
||||||
|
}
|
||||||
|
windows {
|
||||||
|
shortcut = true
|
||||||
|
menu = true
|
||||||
|
dirChooser = true
|
||||||
|
perUserInstall = true
|
||||||
|
}
|
||||||
|
|
||||||
|
macOS {
|
||||||
|
notarization {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes.release.proguard {
|
buildTypes.release.proguard {
|
||||||
configurationFiles.from(project.file("proguard-rules.pro"))
|
configurationFiles.from(project.file("proguard-rules.pro"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 dev.nucleusframework.webview.web.WebViewState
|
import io.github.kdroidfilter.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
|
||||||
|
|||||||
@ -19,7 +19,6 @@ 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
|
||||||
@ -42,7 +41,6 @@ import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
|||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.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
|
||||||
@ -91,11 +89,7 @@ val tabs = listOf(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(
|
@OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class, ExperimentalSharedTransitionApi::class)
|
||||||
KoinExperimentalAPI::class,
|
|
||||||
ExperimentalCoroutinesApi::class,
|
|
||||||
ExperimentalSharedTransitionApi::class
|
|
||||||
)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
content: @Composable () -> Unit = {}
|
content: @Composable () -> Unit = {}
|
||||||
@ -114,29 +108,19 @@ 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()) {
|
AppShell(navigator, navigationState) {
|
||||||
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
SharedTransitionLayout {
|
||||||
|
CompositionLocalProvider(
|
||||||
Box(Modifier.fillMaxSize()) {
|
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
||||||
AppShell(navigator, navigationState) {
|
) {
|
||||||
SharedTransitionLayout {
|
Column {
|
||||||
CompositionLocalProvider(
|
NavDisplay(
|
||||||
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
onBack = navigator::pop,
|
||||||
Column {
|
entries = navigationState.toEntries(koinEntryProvider())
|
||||||
NavDisplay(
|
)
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
onBack = navigator::pop,
|
|
||||||
entries = navigationState.toEntries(koinEntryProvider())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (currentRoute) {
|
|
||||||
Routes.WebView -> WebViewScreen(koinInject())
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
|
|||||||
@ -18,8 +18,9 @@
|
|||||||
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.album.AlbumScreen
|
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
||||||
|
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
||||||
|
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
|
import dev.krtirtho.spotube.modules.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
|
||||||
@ -30,6 +31,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
|||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
import dev.krtirtho.spotube.modules.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,7 +97,9 @@ 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,
|
||||||
|
|||||||
@ -17,9 +17,9 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import dev.nucleusframework.webview.web.WebContent
|
import io.github.kdroidfilter.webview.web.WebContent
|
||||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||||
import dev.nucleusframework.webview.cookie.CookieManager
|
import io.github.kdroidfilter.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
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import dev.nucleusframework.webview.web.WebViewState
|
import io.github.kdroidfilter.webview.web.WebViewState
|
||||||
|
|
||||||
expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)
|
expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,6 @@ 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
|
||||||
@ -52,14 +51,13 @@ 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 dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
|
||||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
import io.github.kdroidfilter.webview.jsbridge.JsMessage
|
||||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
|
||||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
import io.github.kdroidfilter.webview.web.WebView
|
||||||
import dev.nucleusframework.webview.web.WebView
|
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
import io.github.kdroidfilter.webview.web.WebViewState
|
||||||
import dev.nucleusframework.webview.web.WebViewState
|
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
|
||||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
|
||||||
|
|
||||||
class PostMessageHandler(
|
class PostMessageHandler(
|
||||||
private val onMessageReceived: (String) -> Unit = {}
|
private val onMessageReceived: (String) -> Unit = {}
|
||||||
@ -151,63 +149,64 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
topBar = {
|
topBar = {
|
||||||
ApplicationMainBar(
|
Row(
|
||||||
title = {
|
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||||
Row(
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
modifier = Modifier.height(56.dp)
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
navigator.navigateBack()
|
||||||
|
}, enabled = navigator.canGoBack
|
||||||
) {
|
) {
|
||||||
Row(
|
Icon(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
FeatherIcons.ChevronLeft,
|
||||||
horizontalArrangement = Arrangement.Start,
|
contentDescription = "Go back to browser history"
|
||||||
modifier = Modifier.height(56.dp)
|
)
|
||||||
) {
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
navigator.navigateBack()
|
navigator.navigateForward()
|
||||||
}, enabled = navigator.canGoBack
|
}, enabled = navigator.canGoForward
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
FeatherIcons.ChevronLeft,
|
FeatherIcons.ChevronRight,
|
||||||
contentDescription = "Go back to browser history"
|
contentDescription = "Go forward 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),
|
||||||
{ innerPadding ->
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
|||||||
@ -676,20 +676,16 @@ fun PluginScreen(
|
|||||||
val service =
|
val service =
|
||||||
selectedService ?: return@LaunchedEffect
|
selectedService ?: return@LaunchedEffect
|
||||||
|
|
||||||
try {
|
|
||||||
service.use {
|
|
||||||
val pluginRequiresAuth =
|
|
||||||
coreAPI.requiresAuthentication
|
|
||||||
requiresAuth = pluginRequiresAuth
|
|
||||||
if (!pluginRequiresAuth) return@use
|
|
||||||
|
|
||||||
coreAPI.loggedInFlow.collect { loggedIn ->
|
service.use {
|
||||||
isLoggedIn = loggedIn
|
val pluginRequiresAuth =
|
||||||
}
|
coreAPI.requiresAuthentication
|
||||||
|
requiresAuth = pluginRequiresAuth
|
||||||
|
if (!pluginRequiresAuth) return@use
|
||||||
|
|
||||||
|
coreAPI.loggedInFlow.collect { loggedIn ->
|
||||||
|
isLoggedIn = loggedIn
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
|
||||||
// Service may have been stopped/closed
|
|
||||||
// concurrently when the plugin selection changed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import dev.nucleusframework.webview.web.WebViewState
|
import io.github.kdroidfilter.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
|
||||||
|
|||||||
@ -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()) }
|
single { SystemTrayService(get(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.systemtray
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
|
||||||
import dev.nucleusframework.application.NucleusApplicationScope
|
|
||||||
import dev.nucleusframework.composenativetray.tray.api.Tray
|
|
||||||
import org.koin.compose.koinInject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the system tray icon + menu via Compose Native Tray (no AWT/Swing).
|
|
||||||
*
|
|
||||||
* Must be composed inside `nucleusApplication { }` and kept in composition
|
|
||||||
* while the tray should be visible (the menu is fully reactive via
|
|
||||||
* [SystemTrayService.state]).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun NucleusApplicationScope.SystemTray(
|
|
||||||
isWindowVisible: Boolean,
|
|
||||||
onToggleWindowVisibility: () -> Unit,
|
|
||||||
onExit: () -> Unit,
|
|
||||||
) {
|
|
||||||
val trayService = koinInject<SystemTrayService>()
|
|
||||||
val state by trayService.state.collectAsState()
|
|
||||||
|
|
||||||
Tray(
|
|
||||||
icon = Iconsax.IconsaxMusic,
|
|
||||||
tooltip = "Spotube",
|
|
||||||
primaryAction = onToggleWindowVisibility,
|
|
||||||
) {
|
|
||||||
Item(label = if (isWindowVisible) "Hide Window" else "Show Window") {
|
|
||||||
onToggleWindowVisibility()
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Item(label = if (state.isPlaying) "Pause" else "Play") {
|
|
||||||
trayService.togglePlayPause()
|
|
||||||
}
|
|
||||||
Item(label = "Next Track") { trayService.skipToNext() }
|
|
||||||
Item(label = "Previous Track") { trayService.skipToPrevious() }
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
CheckableItem(
|
|
||||||
label = "Shuffle",
|
|
||||||
checked = state.shuffleEnabled,
|
|
||||||
onCheckedChange = trayService::setShuffle,
|
|
||||||
)
|
|
||||||
SubMenu(label = "Loop") {
|
|
||||||
Item(label = "Loop: OFF") { trayService.setLoop(LoopState.NONE) }
|
|
||||||
Item(label = "Loop: ONE") { trayService.setLoop(LoopState.ONE) }
|
|
||||||
Item(label = "Loop: ALL") { trayService.setLoop(LoopState.ALL) }
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Item(label = "Volume: ${(state.volume * 100).toInt()}%")
|
|
||||||
Item(label = "Volume +") { trayService.increaseVolume() }
|
|
||||||
Item(label = "Volume -") { trayService.decreaseVolume() }
|
|
||||||
Item(label = if (state.volume <= 0f) "Unmute" else "Mute") { trayService.toggleMute() }
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
if (state.currentTrackId != null) {
|
|
||||||
Item(label = if (state.isCurrentTrackLiked) "Unlike Track" else "Like Track") {
|
|
||||||
trayService.toggleLike()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Item(label = "Exit") { onExit() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,143 +20,421 @@ 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.stateIn
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
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,
|
||||||
) : AutoCloseable {
|
) : KoinComponent, AutoCloseable {
|
||||||
|
|
||||||
|
private val logger by injectLogger<SystemTrayService>()
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
private val volumeStep = 0.05f
|
private var trayIcon: TrayIcon? = null
|
||||||
|
private var menuDialog: TrayMenuDialog? = null
|
||||||
|
|
||||||
|
private var windowVisible: Boolean = true
|
||||||
|
|
||||||
private var onToggleWindowVisibility: () -> Unit = {}
|
private var onToggleWindowVisibility: () -> Unit = {}
|
||||||
|
private var onExit: () -> Unit = {}
|
||||||
|
|
||||||
val state: StateFlow<TrayState> =
|
private val volumeStep = 0.05f
|
||||||
combine(
|
private var trayEnabled = false
|
||||||
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)
|
|
||||||
|
|
||||||
fun setCallbacks(onToggleWindowVisibility: () -> Unit) {
|
@Volatile
|
||||||
this.onToggleWindowVisibility = onToggleWindowVisibility
|
private var currentState: TrayState? = null
|
||||||
}
|
|
||||||
|
|
||||||
fun hideWindow() = onToggleWindowVisibility()
|
init {
|
||||||
|
|
||||||
fun togglePlayPause() {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (state.value.playerState == PlayerState.PLAYING) audioPlayer.pause() else audioPlayer.play()
|
settingsProvider.settingsState
|
||||||
|
.map { it?.minimizeToTray ?: false }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { enabled ->
|
||||||
|
trayEnabled = enabled
|
||||||
|
if (enabled) {
|
||||||
|
ensureTrayCreated()
|
||||||
|
} else {
|
||||||
|
removeTray()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun skipToNext() {
|
|
||||||
scope.launch { audioPlayer.skipToNext() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipToPrevious() {
|
|
||||||
scope.launch { audioPlayer.skipToPrevious() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShuffle(enabled: Boolean) {
|
|
||||||
scope.launch { audioPlayer.shuffle(enabled) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLoop(loopState: LoopState) {
|
|
||||||
scope.launch { audioPlayer.loop(loopState) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun increaseVolume() {
|
|
||||||
scope.launch { audioPlayer.setVolume((state.value.volume + volumeStep).coerceAtMost(1f)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decreaseVolume() {
|
|
||||||
scope.launch { audioPlayer.setVolume((state.value.volume - volumeStep).coerceAtLeast(0f)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleMute() {
|
|
||||||
scope.launch { audioPlayer.setVolume(if (state.value.volume <= 0f) 0.5f else 0f) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleLike() {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val trackId = (state.value.currentEntry as? QueueEntry.StreamingTrack)?.track?.id ?: return@launch
|
combine(
|
||||||
val isLiked = state.value.savedTrackIds.contains(trackId)
|
audioPlayer.playerStateFlow,
|
||||||
if (isLiked) {
|
audioPlayer.currentMediaItemFlow,
|
||||||
savedTracksRepository.removeSavedTracks(listOf(trackId))
|
audioPlayer.loopStateFlow,
|
||||||
} else {
|
audioPlayer.shuffleModeFlow,
|
||||||
savedTracksRepository.saveTracks(listOf(trackId))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close() {
|
fun setCallbacks(
|
||||||
scope.cancel()
|
onToggleWindowVisibility: () -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
this.onToggleWindowVisibility = onToggleWindowVisibility
|
||||||
|
this.onExit = onExit
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TrayState(
|
fun hideWindow() {
|
||||||
|
if (windowVisible) {
|
||||||
|
windowVisible = false
|
||||||
|
onToggleWindowVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showWindow() {
|
||||||
|
if (!windowVisible) {
|
||||||
|
windowVisible = true
|
||||||
|
onToggleWindowVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureTrayCreated() {
|
||||||
|
if (trayIcon != null) return
|
||||||
|
if (!SystemTray.isSupported()) {
|
||||||
|
logger.w { "System tray is not supported on this platform" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SwingUtilities.invokeLater {
|
||||||
|
try {
|
||||||
|
val trayImage = createTrayIconImage()
|
||||||
|
val icon = TrayIcon(trayImage, "Spotube")
|
||||||
|
icon.isImageAutoSize = true
|
||||||
|
icon.addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseClicked(e: MouseEvent) {
|
||||||
|
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) {
|
||||||
|
if (windowVisible) hideWindow() else showWindow()
|
||||||
|
} else if (e.button == MouseEvent.BUTTON3) {
|
||||||
|
showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
SystemTray.getSystemTray().add(icon)
|
||||||
|
trayIcon = icon
|
||||||
|
logger.i { "System tray created" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.e(e) { "Failed to create system tray" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
removeTray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
|
||||||
|
val dialog = menuDialog
|
||||||
|
if (dialog != null && dialog.isVisible) {
|
||||||
|
dialog.dispose()
|
||||||
|
menuDialog = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newDialog = TrayMenuDialog().apply {
|
||||||
|
isUndecorated = true
|
||||||
|
isAlwaysOnTop = true
|
||||||
|
focusableWindowState = true
|
||||||
|
background = Color(0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val panel = JPanel()
|
||||||
|
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
|
||||||
|
panel.border = BorderFactory.createCompoundBorder(
|
||||||
|
BorderFactory.createLineBorder(Color(80, 80, 80), 1),
|
||||||
|
BorderFactory.createEmptyBorder(4, 4, 4, 4)
|
||||||
|
)
|
||||||
|
panel.background = UIManager.getColor("PopupMenu.background") ?: Color(45, 45, 45)
|
||||||
|
|
||||||
|
newDialog.contentPane.layout = BorderLayout()
|
||||||
|
newDialog.contentPane.add(panel, BorderLayout.CENTER)
|
||||||
|
newDialog.addWindowFocusListener(object : WindowAdapter() {
|
||||||
|
override fun windowLostFocus(e: WindowEvent) {
|
||||||
|
newDialog.isVisible = false
|
||||||
|
newDialog.dispose()
|
||||||
|
if (menuDialog === newDialog) menuDialog = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
menuDialog = newDialog
|
||||||
|
rebuildMenuPanel(panel, newDialog)
|
||||||
|
newDialog.pack()
|
||||||
|
|
||||||
|
val screenBounds = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||||
|
.defaultScreenDevice.defaultConfiguration.bounds
|
||||||
|
val dialogWidth = newDialog.width
|
||||||
|
val dialogHeight = newDialog.height
|
||||||
|
val posX = if (x + dialogWidth > screenBounds.width) x - dialogWidth else x
|
||||||
|
val posY = if (y + dialogHeight > screenBounds.height) y - dialogHeight else y
|
||||||
|
newDialog.setLocation(posX, posY)
|
||||||
|
newDialog.isVisible = true
|
||||||
|
newDialog.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildMenuPanel(panel: JPanel, dialog: JDialog) {
|
||||||
|
panel.removeAll()
|
||||||
|
val state = currentState
|
||||||
|
if (state == null) {
|
||||||
|
panel.add(createDisabledLabel("Loading..."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val media = state.mediaItem
|
||||||
|
val trackTitle = media?.title ?: "No track playing"
|
||||||
|
val trackArtist = media?.artist ?: ""
|
||||||
|
|
||||||
|
panel.add(createDisabledLabel(
|
||||||
|
if (trackArtist.isNotEmpty()) "$trackTitle - $trackArtist" else trackTitle
|
||||||
|
))
|
||||||
|
panel.add(createSeparator())
|
||||||
|
|
||||||
|
panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") {
|
||||||
|
if (windowVisible) hideWindow() else showWindow()
|
||||||
|
})
|
||||||
|
panel.add(createSeparator())
|
||||||
|
|
||||||
|
val isPlaying = state.playerState == PlayerState.PLAYING
|
||||||
|
panel.add(createMenuItem(if (isPlaying) "Pause" else "Play") {
|
||||||
|
scope.launch { if (isPlaying) audioPlayer.pause() else audioPlayer.play() }
|
||||||
|
})
|
||||||
|
panel.add(createMenuItem("Next Track") {
|
||||||
|
scope.launch { audioPlayer.skipToNext() }
|
||||||
|
})
|
||||||
|
panel.add(createMenuItem("Previous Track") {
|
||||||
|
scope.launch { audioPlayer.skipToPrevious() }
|
||||||
|
})
|
||||||
|
panel.add(createSeparator())
|
||||||
|
|
||||||
|
panel.add(createCheckBoxMenuItem("Shuffle", state.shuffleEnabled) {
|
||||||
|
scope.launch { audioPlayer.shuffle(!state.shuffleEnabled) }
|
||||||
|
})
|
||||||
|
|
||||||
|
val loopSubMenu = JPanel()
|
||||||
|
loopSubMenu.layout = BoxLayout(loopSubMenu, BoxLayout.Y_AXIS)
|
||||||
|
loopSubMenu.background = panel.background
|
||||||
|
val loopGroup = javax.swing.ButtonGroup()
|
||||||
|
val loopNone = javax.swing.JRadioButtonMenuItem("Loop: OFF", state.loopState == LoopState.NONE)
|
||||||
|
val loopOne = javax.swing.JRadioButtonMenuItem("Loop: ONE", state.loopState == LoopState.ONE)
|
||||||
|
val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL)
|
||||||
|
|
||||||
|
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||||
|
listOf(loopNone, loopOne, loopAll).forEach { item ->
|
||||||
|
item.isOpaque = true
|
||||||
|
val normalBg = item.background
|
||||||
|
item.addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground }
|
||||||
|
override fun mouseExited(e: MouseEvent) { item.background = normalBg }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loopGroup.add(loopNone)
|
||||||
|
loopGroup.add(loopOne)
|
||||||
|
loopGroup.add(loopAll)
|
||||||
|
loopNone.addActionListener { scope.launch { audioPlayer.loop(LoopState.NONE) } }
|
||||||
|
loopOne.addActionListener { scope.launch { audioPlayer.loop(LoopState.ONE) } }
|
||||||
|
loopAll.addActionListener { scope.launch { audioPlayer.loop(LoopState.ALL) } }
|
||||||
|
loopSubMenu.add(loopNone)
|
||||||
|
loopSubMenu.add(loopOne)
|
||||||
|
loopSubMenu.add(loopAll)
|
||||||
|
panel.add(createSubMenuLabel("Loop"))
|
||||||
|
panel.add(loopSubMenu)
|
||||||
|
panel.add(createSeparator())
|
||||||
|
|
||||||
|
val volumePercent = (state.volume * 100).toInt()
|
||||||
|
panel.add(createDisabledLabel("Volume: $volumePercent%"))
|
||||||
|
panel.add(createMenuItem("Volume +") {
|
||||||
|
scope.launch {
|
||||||
|
val newVol = (state.volume + volumeStep).coerceAtMost(1f)
|
||||||
|
audioPlayer.setVolume(newVol)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
panel.add(createMenuItem("Volume -") {
|
||||||
|
scope.launch {
|
||||||
|
val newVol = (state.volume - volumeStep).coerceAtLeast(0f)
|
||||||
|
audioPlayer.setVolume(newVol)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
val isMuted = state.volume <= 0f
|
||||||
|
panel.add(createMenuItem(if (isMuted) "Unmute" else "Mute") {
|
||||||
|
scope.launch { audioPlayer.setVolume(if (isMuted) 0.5f else 0f) }
|
||||||
|
})
|
||||||
|
panel.add(createSeparator())
|
||||||
|
|
||||||
|
val currentTrackId = (state.currentEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||||
|
val isLiked = currentTrackId != null && state.savedTrackIds.contains(currentTrackId)
|
||||||
|
val likeLabel = if (currentTrackId == null) "Like (No track)" else if (isLiked) "Unlike Track" else "Like Track"
|
||||||
|
panel.add(createMenuItem(likeLabel, enabled = currentTrackId != null) {
|
||||||
|
val trackId = currentTrackId ?: return@createMenuItem
|
||||||
|
scope.launch {
|
||||||
|
if (isLiked) savedTracksRepository.removeSavedTracks(listOf(trackId))
|
||||||
|
else savedTracksRepository.saveTracks(listOf(trackId))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
panel.add(createSeparator())
|
||||||
|
|
||||||
|
panel.add(createMenuItem("Exit") { onExit() })
|
||||||
|
|
||||||
|
panel.revalidate()
|
||||||
|
panel.repaint()
|
||||||
|
dialog.pack()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateTooltip(state: TrayState) {
|
||||||
|
val media = state.mediaItem
|
||||||
|
trayIcon?.toolTip = if (media != null) {
|
||||||
|
"Spotube - ${media.title} by ${media.artist}"
|
||||||
|
} else {
|
||||||
|
"Spotube"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMenuItem(label: String, enabled: Boolean = true, action: () -> Unit): JMenuItem {
|
||||||
|
return JMenuItem(label).apply {
|
||||||
|
isEnabled = enabled
|
||||||
|
isOpaque = true
|
||||||
|
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||||
|
addActionListener { action() }
|
||||||
|
val normalBg = background
|
||||||
|
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||||
|
addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
|
||||||
|
override fun mouseExited(e: MouseEvent) { background = normalBg }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCheckBoxMenuItem(label: String, selected: Boolean, action: () -> Unit): JCheckBoxMenuItem {
|
||||||
|
return JCheckBoxMenuItem(label, selected).apply {
|
||||||
|
isOpaque = true
|
||||||
|
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||||
|
addActionListener { action() }
|
||||||
|
val normalBg = background
|
||||||
|
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||||
|
addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
|
||||||
|
override fun mouseExited(e: MouseEvent) { background = normalBg }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDisabledLabel(text: String): JLabel {
|
||||||
|
return JLabel(text).apply {
|
||||||
|
foreground = Color(150, 150, 150)
|
||||||
|
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||||
|
alignmentX = java.awt.Component.LEFT_ALIGNMENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSubMenuLabel(text: String): JLabel {
|
||||||
|
return JLabel(text).apply {
|
||||||
|
foreground = UIManager.getColor("MenuItem.foreground") ?: Color.WHITE
|
||||||
|
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||||
|
alignmentX = java.awt.Component.LEFT_ALIGNMENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSeparator(): javax.swing.JSeparator {
|
||||||
|
return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTrayIconImage(): Image {
|
||||||
|
val resource = javaClass.classLoader.getResource("icon.png")
|
||||||
|
?: javaClass.classLoader.getResource("icons/spotube.png")
|
||||||
|
if (resource != null) {
|
||||||
|
return Toolkit.getDefaultToolkit().getImage(resource)
|
||||||
|
}
|
||||||
|
val img = BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val g = img.createGraphics()
|
||||||
|
g.color = Color(34, 197, 94)
|
||||||
|
g.fillOval(0, 0, 16, 16)
|
||||||
|
g.color = Color.WHITE
|
||||||
|
g.fillOval(5, 5, 6, 6)
|
||||||
|
g.dispose()
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TrayMenuDialog : JDialog()
|
||||||
|
|
||||||
|
private data class TrayState(
|
||||||
val playerState: PlayerState,
|
val playerState: PlayerState,
|
||||||
val mediaItem: MediaItem?,
|
val mediaItem: dev.krtirtho.spotube.core.audioplayer.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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,6 @@ 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
|
||||||
@ -45,10 +44,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -60,27 +55,27 @@ actual fun ApplicationMainBar(
|
|||||||
actions: @Composable (RowScope.() -> Unit),
|
actions: @Composable (RowScope.() -> Unit),
|
||||||
backButton: Boolean,
|
backButton: Boolean,
|
||||||
) {
|
) {
|
||||||
Box(
|
LocalWindowScope.current.WindowDraggableArea {
|
||||||
modifier = Modifier.fillMaxWidth()
|
Box(
|
||||||
.windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize)
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
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
|
||||||
)
|
)
|
||||||
LocalWindowScope.current.WindowControls(
|
WindowButtons(
|
||||||
modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
renderer = WindowControlsRenderer.Platform, // default
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,7 @@ 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< NucleusApplicationScope> {
|
val LocalApplicationScope = staticCompositionLocalOf<ApplicationScope> {
|
||||||
error("No ApplicationScope found in LocalApplicationScope")
|
error("No ApplicationScope found in LocalApplicationScope")
|
||||||
}
|
}
|
||||||
@ -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<NucleusDecoratedWindowScope> {
|
val LocalWindowScope = compositionLocalOf<FrameWindowScope> {
|
||||||
error("No Window found in LocalWindowScope")
|
error("No Window found in LocalWindowScope")
|
||||||
}
|
}
|
||||||
val LocalWindowState = compositionLocalOf<WindowState> {
|
val LocalWindowState = compositionLocalOf<WindowState> {
|
||||||
|
|||||||
@ -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 dev.nucleusframework.webview.web.WebViewState
|
import io.github.kdroidfilter.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
|
||||||
|
|||||||
@ -25,21 +25,18 @@ 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
|
||||||
@ -57,39 +54,14 @@ 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)
|
||||||
|
|
||||||
nucleusApplication(backend = NucleusBackend.Tao) {
|
application {
|
||||||
val windowState = rememberWindowState(
|
val windowState = rememberWindowState(
|
||||||
width = 1080.dp,
|
width = 1080.dp,
|
||||||
height = 720.dp
|
height = 720.dp
|
||||||
@ -103,24 +75,16 @@ fun main() {
|
|||||||
|
|
||||||
var isWindowVisible by remember { mutableStateOf(true) }
|
var isWindowVisible by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }
|
trayService.setCallbacks(
|
||||||
val onExit = {
|
onToggleWindowVisibility = { isWindowVisible = !isWindowVisible },
|
||||||
trayService.close()
|
onExit = {
|
||||||
appScope.cancel()
|
trayService.close()
|
||||||
exitApplication()
|
appScope.cancel()
|
||||||
}
|
exitApplication()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
trayService.setCallbacks(onToggleWindowVisibility)
|
Window(
|
||||||
|
|
||||||
if (minimizeToTray) {
|
|
||||||
SystemTray(
|
|
||||||
isWindowVisible = isWindowVisible,
|
|
||||||
onToggleWindowVisibility = onToggleWindowVisibility,
|
|
||||||
onExit = onExit,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DecoratedWindow(
|
|
||||||
state = windowState,
|
state = windowState,
|
||||||
onCloseRequest = {
|
onCloseRequest = {
|
||||||
if (minimizeToTray) {
|
if (minimizeToTray) {
|
||||||
@ -132,11 +96,12 @@ fun main() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = "Spotube",
|
title = "Spotube",
|
||||||
|
decoration = WindowDecoration.Undecorated(),
|
||||||
visible = isWindowVisible,
|
visible = isWindowVisible,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalApplicationScope provides this@nucleusApplication,
|
LocalApplicationScope provides this@application,
|
||||||
LocalWindowScope provides this@DecoratedWindow,
|
LocalWindowScope provides this@Window,
|
||||||
LocalWindowState provides windowState
|
LocalWindowState provides windowState
|
||||||
) {
|
) {
|
||||||
App()
|
App()
|
||||||
|
|||||||
@ -47,18 +47,16 @@ 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.4.10"
|
kotlinStdlib = "2.3.21"
|
||||||
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 = "1.0.1"
|
compose-webview = "0.1.0-SNAPSHOT"
|
||||||
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"
|
||||||
@ -121,9 +119,6 @@ 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" }
|
||||||
@ -145,8 +140,7 @@ 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 = "dev.nucleusframework:composewebview", version.ref = "compose-webview" }
|
compose-webview = { module = "io.github.kdroidfilter: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" }
|
||||||
@ -189,4 +183,3 @@ 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" }
|
|
||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -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-9.4.0-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@ -20,7 +20,6 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
|||||||
|
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
mavenLocal()
|
|
||||||
google {
|
google {
|
||||||
mavenContent {
|
mavenContent {
|
||||||
includeGroupAndSubgroups("androidx")
|
includeGroupAndSubgroups("androidx")
|
||||||
@ -30,13 +29,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")
|
||||||
@ -46,6 +45,7 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven("https://jitpack.io")
|
maven("https://jitpack.io")
|
||||||
|
mavenLocal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user