From cfaea59e2f2d24e721627540c9394ffb9fb1bb09 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 22 Jul 2026 16:54:11 +0600 Subject: [PATCH] feat(systemtray): self-manage tray lifecycle via Koin DI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SystemTrayService now observes settings internally and creates/removes the tray icon when minimizeToTray toggles. No more external start/stop calls from composables — eliminates infinite tray icon recreation. - SystemTrayService init block subscribes to settings changes - Close button uses hideWindow() when tray enabled - Double-click uses hideWindow()/showWindow() for consistent state - main.kt stripped of LaunchedEffect/remember tray management - ApplicationMainBar close button checks minimizeToTray setting --- .../dev/krtirtho/spotube/core/paths/Paths.kt | 2 +- .../core/systemtray/SystemTrayService.kt | 224 +++++++++--------- .../ui/component/ApplicationMainBar.jvm.kt | 54 ++--- .../kotlin/dev/krtirtho/spotube/main.kt | 34 +-- 4 files changed, 145 insertions(+), 169 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt index a44f2f6f..17ac0708 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt @@ -18,7 +18,7 @@ package dev.krtirtho.spotube.core.paths @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -expect class Paths { +expect class Paths() { fun getApplicationCacheDirPath(): String fun getApplicationDataDirPath(): String fun getUserDownloadsDirPath(): String 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 064929e0..ee3f54fe 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 @@ -29,6 +29,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import java.awt.BorderLayout @@ -38,8 +40,6 @@ import java.awt.Image import java.awt.SystemTray import java.awt.Toolkit import java.awt.TrayIcon -import java.awt.event.FocusAdapter -import java.awt.event.FocusEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.awt.event.WindowAdapter @@ -50,10 +50,8 @@ import javax.swing.BoxLayout import javax.swing.JCheckBoxMenuItem import javax.swing.JDialog import javax.swing.JLabel -import javax.swing.JMenu import javax.swing.JMenuItem import javax.swing.JPanel -import javax.swing.JPopupMenu import javax.swing.SwingUtilities import javax.swing.UIManager @@ -71,26 +69,87 @@ class SystemTrayService( private var menuDialog: TrayMenuDialog? = null private var windowVisible: Boolean = true - private var onToggleWindowVisibility: (() -> Unit)? = null - private var onExit: (() -> Unit)? = null - @Volatile - private var started = false + private var onToggleWindowVisibility: () -> Unit = {} + private var onExit: () -> Unit = {} private val volumeStep = 0.05f + private var trayEnabled = false - fun start( + @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, ) { this.onToggleWindowVisibility = onToggleWindowVisibility this.onExit = onExit + } - if (started) { - return + fun hideWindow() { + if (windowVisible) { + windowVisible = false + onToggleWindowVisibility() } - started = true + } + 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 @@ -104,25 +163,39 @@ class SystemTrayService( icon.addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) { - onToggleWindowVisibility.invoke() + if (windowVisible) hideWindow() else showWindow() } else if (e.button == MouseEvent.BUTTON3) { showMenuDialog(icon, e.xOnScreen, e.yOnScreen) } } }) - val systemTray = SystemTray.getSystemTray() - systemTray.add(icon) - this.trayIcon = icon - - logger.i { "System tray initialized" } - observeState() + SystemTray.getSystemTray().add(icon) + trayIcon = icon + logger.i { "System tray created" } } catch (e: Exception) { - logger.e(e) { "Failed to initialize system tray" } + 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) { @@ -135,7 +208,6 @@ class SystemTrayService( isUndecorated = true isAlwaysOnTop = true focusableWindowState = true - setAutoRequestFocus(true) background = Color(0, 0, 0, 0) } @@ -190,7 +262,7 @@ class SystemTrayService( panel.add(createSeparator()) panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") { - onToggleWindowVisibility?.invoke() + if (windowVisible) hideWindow() else showWindow() }) panel.add(createSeparator()) @@ -219,18 +291,12 @@ class SystemTrayService( 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 normalBackground = item.background + val normalBg = item.background item.addMouseListener(object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - item.background = hoverBackground - } - - override fun mouseExited(e: MouseEvent) { - item.background = normalBackground - } + override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground } + override fun mouseExited(e: MouseEvent) { item.background = normalBg } }) } @@ -279,53 +345,16 @@ class SystemTrayService( }) panel.add(createSeparator()) - panel.add(createMenuItem("Exit") { onExit?.invoke() }) + panel.add(createMenuItem("Exit") { onExit() }) panel.revalidate() panel.repaint() dialog.pack() } - @Volatile - private var currentState: TrayState? = null - - private fun observeState() { - 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) - } - } - } - } - } - private fun updateTooltip(state: TrayState) { - val icon = trayIcon ?: return val media = state.mediaItem - icon.toolTip = if (media != null) { + trayIcon?.toolTip = if (media != null) { "Spotube - ${media.title} by ${media.artist}" } else { "Spotube" @@ -338,20 +367,11 @@ class SystemTrayService( isOpaque = true maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) addActionListener { action() } - - val normalBackground = background - val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) - + val normalBg = background + val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) addMouseListener(object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - if (isEnabled) { - background = hoverBackground - } - } - - override fun mouseExited(e: MouseEvent) { - background = normalBackground - } + override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg } + override fun mouseExited(e: MouseEvent) { background = normalBg } }) } } @@ -361,20 +381,11 @@ class SystemTrayService( isOpaque = true maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) addActionListener { action() } - - val normalBackground = background - val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) - + val normalBg = background + val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) addMouseListener(object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - if (isEnabled) { - background = hoverBackground - } - } - - override fun mouseExited(e: MouseEvent) { - background = normalBackground - } + override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg } + override fun mouseExited(e: MouseEvent) { background = normalBg } }) } } @@ -396,26 +407,7 @@ class SystemTrayService( } private fun createSeparator(): javax.swing.JSeparator { - return javax.swing.JSeparator().apply { - maximumSize = Dimension(Int.MAX_VALUE, 2) - } - } - - fun setWindowVisible(visible: Boolean) { - windowVisible = visible - } - - override fun close() { - menuDialog?.dispose() - menuDialog = null - trayIcon?.let { - try { - SystemTray.getSystemTray().remove(it) - } catch (e: Exception) { - logger.e(e) { "Failed to remove tray icon" } - } - } - trayIcon = null + return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) } } private fun createTrayIconImage(): Image { diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index 18f79d85..c70627dc 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -84,52 +84,34 @@ private fun WindowButtons(modifier: Modifier = Modifier) { val windowState = LocalWindowState.current val applicationScope = LocalApplicationScope.current val settingsProvider = koinInject() - val systemTrayService = koinInject() + val trayService = koinInject() val settings by settingsProvider.settingsState.collectAsState(initial = null) - val minimizeToTray = settings?.minimizeToTray ?: false Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween ) { - IconButton( - onClick = { - windowState.isMinimized = true - }) { - Icon( - Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp) - ) + IconButton(onClick = { windowState.isMinimized = true }) { + Icon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)) } - IconButton( - onClick = { - windowState.placement = if (windowState.placement == WindowPlacement.Maximized) { - WindowPlacement.Floating - } else { - WindowPlacement.Maximized - } - }) { + IconButton(onClick = { + windowState.placement = if (windowState.placement == WindowPlacement.Maximized) + WindowPlacement.Floating else WindowPlacement.Maximized + }) { if (windowState.placement == WindowPlacement.Floating) { - Icon( - Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp) - ) + Icon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)) } else { - Icon( - Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp) - ) + Icon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)) } } - IconButton( - onClick = { - if (minimizeToTray) { - windowState.isMinimized = true - systemTrayService.setWindowVisible(false) - } else { - applicationScope.exitApplication() - } - }) { - Icon( - Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp) - ) + IconButton(onClick = { + if (settings?.minimizeToTray == true) { + trayService.hideWindow() + } else { + applicationScope.exitApplication() + } + }) { + Icon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)) } } -} \ No newline at end of file +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index cf2f0b91..e03b79f5 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -20,6 +20,9 @@ package dev.krtirtho.spotube import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window @@ -68,22 +71,33 @@ fun main() { val settings by settingsProvider.settingsState.collectAsState(initial = null) val minimizeToTray = settings?.minimizeToTray ?: false - val systemTrayService = KoinServicesProvider.systemTrayService + val trayService = KoinServicesProvider.systemTrayService + + var isWindowVisible by remember { mutableStateOf(true) } + + trayService.setCallbacks( + onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }, + onExit = { + trayService.close() + appScope.cancel() + exitApplication() + } + ) Window( state = windowState, onCloseRequest = { if (minimizeToTray) { - windowState.isMinimized = true - systemTrayService.setWindowVisible(false) + isWindowVisible = false } else { + trayService.close() appScope.cancel() exitApplication() } }, title = "Spotube", decoration = WindowDecoration.Undecorated(), - visible = true, + visible = isWindowVisible, ) { CompositionLocalProvider( LocalApplicationScope provides this@application, @@ -93,17 +107,5 @@ fun main() { App() } } - - systemTrayService.start( - onToggleWindowVisibility = { - windowState.isMinimized = !windowState.isMinimized - systemTrayService.setWindowVisible(!windowState.isMinimized) - }, - onExit = { - systemTrayService.close() - appScope.cancel() - exitApplication() - } - ) } }