feat(systemtray): self-manage tray lifecycle via Koin DI

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
This commit is contained in:
Kingkor Roy Tirtho 2026-07-22 16:54:11 +06:00
parent 3448e16ed2
commit cfaea59e2f
4 changed files with 145 additions and 169 deletions

View File

@ -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

View File

@ -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<String>),
)
}.collect { state ->
currentState = state
SwingUtilities.invokeLater {
updateTooltip(state)
menuDialog?.let { dialog ->
val panel = dialog.contentPane.getComponent(0) as? JPanel
if (panel != null) rebuildMenuPanel(panel, dialog)
}
}
}
}
}
fun setCallbacks(
onToggleWindowVisibility: () -> Unit,
onExit: () -> Unit,
) {
this.onToggleWindowVisibility = onToggleWindowVisibility
this.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<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)
}
}
}
}
}
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 {

View File

@ -84,52 +84,34 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
val windowState = LocalWindowState.current
val applicationScope = LocalApplicationScope.current
val settingsProvider = koinInject<SettingsProvider>()
val systemTrayService = koinInject<SystemTrayService>()
val trayService = koinInject<SystemTrayService>()
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))
}
}
}
}

View File

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