From e0b9ab530d7bfe251a3197bfac4ce7f92b58b30e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 14 Aug 2026 21:41:09 +0600 Subject: [PATCH] feat: implement system tray functionality with Compose Native Tray integration --- composeApp/build.gradle.kts | 1 + .../krtirtho/spotube/core/di/Modules.jvm.kt | 2 +- .../spotube/core/systemtray/SystemTray.kt | 90 ++++ .../core/systemtray/SystemTrayService.kt | 470 ++++-------------- .../kotlin/dev/krtirtho/spotube/main.kt | 25 +- gradle/libs.versions.toml | 2 + 6 files changed, 207 insertions(+), 383 deletions(-) create mode 100644 composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d753b416..4bab65dd 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -225,6 +225,7 @@ kotlin { implementation(libs.nucleus.core.runtime) implementation(libs.nucleus.nucleus.application) implementation(libs.nucleus.decorated.window.tao) + implementation(libs.compose.native.tray) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt index 711a9089..73d65bc6 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt @@ -33,5 +33,5 @@ actual val platformModules = module { single { AudioPlayer(Unit) } single { JvmLocalMediaDiscoveryService() } single { JvmShareService() } - single { SystemTrayService(get(), get(), get(), get()) } + single { SystemTrayService(get(), get(), get()) } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt new file mode 100644 index 00000000..bb3e4d29 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt @@ -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 . + */ + +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() + 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() } + } +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt index ee3f54fe..b9dfe02c 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt @@ -20,421 +20,143 @@ package dev.krtirtho.spotube.core.systemtray import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState +import dev.krtirtho.spotube.core.audioplayer.MediaItem import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository -import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import java.awt.BorderLayout -import java.awt.Color -import java.awt.Dimension -import java.awt.Image -import java.awt.SystemTray -import java.awt.Toolkit -import java.awt.TrayIcon -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.awt.event.WindowAdapter -import java.awt.event.WindowEvent -import java.awt.image.BufferedImage -import javax.swing.BorderFactory -import javax.swing.BoxLayout -import javax.swing.JCheckBoxMenuItem -import javax.swing.JDialog -import javax.swing.JLabel -import javax.swing.JMenuItem -import javax.swing.JPanel -import javax.swing.SwingUtilities -import javax.swing.UIManager +/** + * State holder for the system tray. Owns the audio state projected into the + * tray menu and exposes the actions the menu items trigger. It no longer + * touches AWT/Swing — the tray itself is rendered by the [SystemTray] + * composable via Compose Native Tray, so it stays compatible with the + * no-AWT Tao backend. + */ class SystemTrayService( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val settingsProvider: SettingsProvider, private val savedTracksRepository: SavedTracksRepository, -) : KoinComponent, AutoCloseable { +) : AutoCloseable { - private val logger by injectLogger() private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private var trayIcon: TrayIcon? = null - private var menuDialog: TrayMenuDialog? = null - - private var windowVisible: Boolean = true + private val volumeStep = 0.05f private var onToggleWindowVisibility: () -> Unit = {} - private var onExit: () -> Unit = {} - private val volumeStep = 0.05f - private var trayEnabled = false + val state: StateFlow = + combine( + audioPlayer.playerStateFlow, + audioPlayer.currentMediaItemFlow, + audioPlayer.loopStateFlow, + audioPlayer.shuffleModeFlow, + audioPlayer.volumeFlow, + audioPlayerQueue.currentQueueEntryFlow, + savedTracksRepository.savedTracksIdsFlow, + ) { values -> + TrayState( + playerState = values[0] as PlayerState, + mediaItem = values[1] as MediaItem?, + loopState = values[2] as LoopState, + shuffleEnabled = values[3] as Boolean, + volume = values[4] as Float, + currentEntry = values[5] as QueueEntry?, + savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set), + ) + }.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial) - @Volatile - private var currentState: TrayState? = null - - init { - scope.launch { - settingsProvider.settingsState - .map { it?.minimizeToTray ?: false } - .distinctUntilChanged() - .collect { enabled -> - trayEnabled = enabled - if (enabled) { - ensureTrayCreated() - } else { - removeTray() - } - } - } - - scope.launch { - combine( - audioPlayer.playerStateFlow, - audioPlayer.currentMediaItemFlow, - audioPlayer.loopStateFlow, - audioPlayer.shuffleModeFlow, - audioPlayer.volumeFlow, - audioPlayerQueue.currentQueueEntryFlow, - savedTracksRepository.savedTracksIdsFlow, - ) { values -> - TrayState( - playerState = values[0] as PlayerState, - mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?, - loopState = values[2] as LoopState, - shuffleEnabled = values[3] as Boolean, - volume = values[4] as Float, - currentEntry = values[5] as QueueEntry?, - savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set), - ) - }.collect { state -> - currentState = state - SwingUtilities.invokeLater { - updateTooltip(state) - menuDialog?.let { dialog -> - val panel = dialog.contentPane.getComponent(0) as? JPanel - if (panel != null) rebuildMenuPanel(panel, dialog) - } - } - } - } - } - - fun setCallbacks( - onToggleWindowVisibility: () -> Unit, - onExit: () -> Unit, - ) { + fun setCallbacks(onToggleWindowVisibility: () -> Unit) { this.onToggleWindowVisibility = onToggleWindowVisibility - this.onExit = onExit } - fun hideWindow() { - if (windowVisible) { - windowVisible = false - onToggleWindowVisibility() + fun hideWindow() = onToggleWindowVisibility() + + fun togglePlayPause() { + scope.launch { + if (state.value.playerState == PlayerState.PLAYING) audioPlayer.pause() else audioPlayer.play() } } - fun showWindow() { - if (!windowVisible) { - windowVisible = true - onToggleWindowVisibility() - } + fun skipToNext() { + scope.launch { audioPlayer.skipToNext() } } - private fun ensureTrayCreated() { - if (trayIcon != null) return - if (!SystemTray.isSupported()) { - logger.w { "System tray is not supported on this platform" } - return - } + fun skipToPrevious() { + scope.launch { audioPlayer.skipToPrevious() } + } - SwingUtilities.invokeLater { - try { - val trayImage = createTrayIconImage() - val icon = TrayIcon(trayImage, "Spotube") - icon.isImageAutoSize = true - icon.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) { - if (windowVisible) hideWindow() else showWindow() - } else if (e.button == MouseEvent.BUTTON3) { - showMenuDialog(icon, e.xOnScreen, e.yOnScreen) - } - } - }) + fun setShuffle(enabled: Boolean) { + scope.launch { audioPlayer.shuffle(enabled) } + } - SystemTray.getSystemTray().add(icon) - trayIcon = icon - logger.i { "System tray created" } - } catch (e: Exception) { - logger.e(e) { "Failed to create system tray" } + fun setLoop(loopState: LoopState) { + scope.launch { audioPlayer.loop(loopState) } + } + + fun increaseVolume() { + scope.launch { audioPlayer.setVolume((state.value.volume + volumeStep).coerceAtMost(1f)) } + } + + fun decreaseVolume() { + scope.launch { audioPlayer.setVolume((state.value.volume - volumeStep).coerceAtLeast(0f)) } + } + + fun toggleMute() { + scope.launch { audioPlayer.setVolume(if (state.value.volume <= 0f) 0.5f else 0f) } + } + + fun toggleLike() { + scope.launch { + val trackId = (state.value.currentEntry as? QueueEntry.StreamingTrack)?.track?.id ?: return@launch + val isLiked = state.value.savedTrackIds.contains(trackId) + if (isLiked) { + savedTracksRepository.removeSavedTracks(listOf(trackId)) + } else { + savedTracksRepository.saveTracks(listOf(trackId)) } } } - private fun removeTray() { - menuDialog?.dispose() - menuDialog = null - trayIcon?.let { - try { - SystemTray.getSystemTray().remove(it) - } catch (e: Exception) { - logger.e(e) { "Failed to remove tray icon" } - } - } - trayIcon = null - } - override fun close() { - removeTray() + scope.cancel() } - private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) { - val dialog = menuDialog - if (dialog != null && dialog.isVisible) { - dialog.dispose() - menuDialog = null - return - } - - val newDialog = TrayMenuDialog().apply { - isUndecorated = true - isAlwaysOnTop = true - focusableWindowState = true - background = Color(0, 0, 0, 0) - } - - val panel = JPanel() - panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) - panel.border = BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(Color(80, 80, 80), 1), - BorderFactory.createEmptyBorder(4, 4, 4, 4) - ) - panel.background = UIManager.getColor("PopupMenu.background") ?: Color(45, 45, 45) - - newDialog.contentPane.layout = BorderLayout() - newDialog.contentPane.add(panel, BorderLayout.CENTER) - newDialog.addWindowFocusListener(object : WindowAdapter() { - override fun windowLostFocus(e: WindowEvent) { - newDialog.isVisible = false - newDialog.dispose() - if (menuDialog === newDialog) menuDialog = null - } - }) - - menuDialog = newDialog - rebuildMenuPanel(panel, newDialog) - newDialog.pack() - - val screenBounds = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment() - .defaultScreenDevice.defaultConfiguration.bounds - val dialogWidth = newDialog.width - val dialogHeight = newDialog.height - val posX = if (x + dialogWidth > screenBounds.width) x - dialogWidth else x - val posY = if (y + dialogHeight > screenBounds.height) y - dialogHeight else y - newDialog.setLocation(posX, posY) - newDialog.isVisible = true - newDialog.requestFocus() - } - - private fun rebuildMenuPanel(panel: JPanel, dialog: JDialog) { - panel.removeAll() - val state = currentState - if (state == null) { - panel.add(createDisabledLabel("Loading...")) - return - } - - val media = state.mediaItem - val trackTitle = media?.title ?: "No track playing" - val trackArtist = media?.artist ?: "" - - panel.add(createDisabledLabel( - if (trackArtist.isNotEmpty()) "$trackTitle - $trackArtist" else trackTitle - )) - panel.add(createSeparator()) - - panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") { - if (windowVisible) hideWindow() else showWindow() - }) - panel.add(createSeparator()) - - val isPlaying = state.playerState == PlayerState.PLAYING - panel.add(createMenuItem(if (isPlaying) "Pause" else "Play") { - scope.launch { if (isPlaying) audioPlayer.pause() else audioPlayer.play() } - }) - panel.add(createMenuItem("Next Track") { - scope.launch { audioPlayer.skipToNext() } - }) - panel.add(createMenuItem("Previous Track") { - scope.launch { audioPlayer.skipToPrevious() } - }) - panel.add(createSeparator()) - - panel.add(createCheckBoxMenuItem("Shuffle", state.shuffleEnabled) { - scope.launch { audioPlayer.shuffle(!state.shuffleEnabled) } - }) - - val loopSubMenu = JPanel() - loopSubMenu.layout = BoxLayout(loopSubMenu, BoxLayout.Y_AXIS) - loopSubMenu.background = panel.background - val loopGroup = javax.swing.ButtonGroup() - val loopNone = javax.swing.JRadioButtonMenuItem("Loop: OFF", state.loopState == LoopState.NONE) - val loopOne = javax.swing.JRadioButtonMenuItem("Loop: ONE", state.loopState == LoopState.ONE) - val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL) - - val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) - listOf(loopNone, loopOne, loopAll).forEach { item -> - item.isOpaque = true - val normalBg = item.background - item.addMouseListener(object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground } - override fun mouseExited(e: MouseEvent) { item.background = normalBg } - }) - } - - loopGroup.add(loopNone) - loopGroup.add(loopOne) - loopGroup.add(loopAll) - loopNone.addActionListener { scope.launch { audioPlayer.loop(LoopState.NONE) } } - loopOne.addActionListener { scope.launch { audioPlayer.loop(LoopState.ONE) } } - loopAll.addActionListener { scope.launch { audioPlayer.loop(LoopState.ALL) } } - loopSubMenu.add(loopNone) - loopSubMenu.add(loopOne) - loopSubMenu.add(loopAll) - panel.add(createSubMenuLabel("Loop")) - panel.add(loopSubMenu) - panel.add(createSeparator()) - - val volumePercent = (state.volume * 100).toInt() - panel.add(createDisabledLabel("Volume: $volumePercent%")) - panel.add(createMenuItem("Volume +") { - scope.launch { - val newVol = (state.volume + volumeStep).coerceAtMost(1f) - audioPlayer.setVolume(newVol) - } - }) - panel.add(createMenuItem("Volume -") { - scope.launch { - val newVol = (state.volume - volumeStep).coerceAtLeast(0f) - audioPlayer.setVolume(newVol) - } - }) - val isMuted = state.volume <= 0f - panel.add(createMenuItem(if (isMuted) "Unmute" else "Mute") { - scope.launch { audioPlayer.setVolume(if (isMuted) 0.5f else 0f) } - }) - panel.add(createSeparator()) - - val currentTrackId = (state.currentEntry as? QueueEntry.StreamingTrack)?.track?.id - val isLiked = currentTrackId != null && state.savedTrackIds.contains(currentTrackId) - val likeLabel = if (currentTrackId == null) "Like (No track)" else if (isLiked) "Unlike Track" else "Like Track" - panel.add(createMenuItem(likeLabel, enabled = currentTrackId != null) { - val trackId = currentTrackId ?: return@createMenuItem - scope.launch { - if (isLiked) savedTracksRepository.removeSavedTracks(listOf(trackId)) - else savedTracksRepository.saveTracks(listOf(trackId)) - } - }) - panel.add(createSeparator()) - - panel.add(createMenuItem("Exit") { onExit() }) - - panel.revalidate() - panel.repaint() - dialog.pack() - } - - private fun updateTooltip(state: TrayState) { - val media = state.mediaItem - trayIcon?.toolTip = if (media != null) { - "Spotube - ${media.title} by ${media.artist}" - } else { - "Spotube" - } - } - - private fun createMenuItem(label: String, enabled: Boolean = true, action: () -> Unit): JMenuItem { - return JMenuItem(label).apply { - isEnabled = enabled - isOpaque = true - maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) - addActionListener { action() } - val normalBg = background - val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) - addMouseListener(object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg } - override fun mouseExited(e: MouseEvent) { background = normalBg } - }) - } - } - - private fun createCheckBoxMenuItem(label: String, selected: Boolean, action: () -> Unit): JCheckBoxMenuItem { - return JCheckBoxMenuItem(label, selected).apply { - isOpaque = true - maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) - addActionListener { action() } - val normalBg = background - val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) - addMouseListener(object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg } - override fun mouseExited(e: MouseEvent) { background = normalBg } - }) - } - } - - private fun createDisabledLabel(text: String): JLabel { - return JLabel(text).apply { - foreground = Color(150, 150, 150) - maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) - alignmentX = java.awt.Component.LEFT_ALIGNMENT - } - } - - private fun createSubMenuLabel(text: String): JLabel { - return JLabel(text).apply { - foreground = UIManager.getColor("MenuItem.foreground") ?: Color.WHITE - maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) - alignmentX = java.awt.Component.LEFT_ALIGNMENT - } - } - - private fun createSeparator(): javax.swing.JSeparator { - return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) } - } - - private fun createTrayIconImage(): Image { - val resource = javaClass.classLoader.getResource("icon.png") - ?: javaClass.classLoader.getResource("icons/spotube.png") - if (resource != null) { - return Toolkit.getDefaultToolkit().getImage(resource) - } - val img = BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB) - val g = img.createGraphics() - g.color = Color(34, 197, 94) - g.fillOval(0, 0, 16, 16) - g.color = Color.WHITE - g.fillOval(5, 5, 6, 6) - g.dispose() - return img - } - - private class TrayMenuDialog : JDialog() - - private data class TrayState( + data class TrayState( val playerState: PlayerState, - val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, + val mediaItem: MediaItem?, val loopState: LoopState, val shuffleEnabled: Boolean, val volume: Float, val currentEntry: QueueEntry?, val savedTrackIds: Set, - ) + ) { + 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(), + ) + } + } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index 7a9fdec6..e99718ed 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.window.rememberWindowState import dev.krtirtho.spotube.core.di.initKoin import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.paths.Paths +import dev.krtirtho.spotube.core.systemtray.SystemTray import dev.krtirtho.spotube.core.systemtray.SystemTrayService import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope import dev.krtirtho.spotube.core.ui.component.LocalWindowScope @@ -75,14 +76,22 @@ fun main() { var isWindowVisible by remember { mutableStateOf(true) } - trayService.setCallbacks( - onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }, - onExit = { - trayService.close() - appScope.cancel() - exitApplication() - } - ) + val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible } + val onExit = { + trayService.close() + appScope.cancel() + exitApplication() + } + + trayService.setCallbacks(onToggleWindowVisibility) + + if (minimizeToTray) { + SystemTray( + isWindowVisible = isWindowVisible, + onToggleWindowVisibility = onToggleWindowVisibility, + onExit = onExit, + ) + } DecoratedWindow( state = windowState, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 35dabd8f..13f66ed8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ runner = "1.7.0" core = "1.7.0" filekit = "0.14.2" compose-webview = "1.0.1" +composeNativeTray = "2.0.3" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" compose-multiplatform-adaptive = "1.3.0-beta02" @@ -145,6 +146,7 @@ filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.re filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" } filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" } compose-webview = { module = "dev.nucleusframework:composewebview", version.ref = "compose-webview" } +compose-native-tray = { module = "dev.nucleusframework:composenativetray", version.ref = "composeNativeTray" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }