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 package dev.krtirtho.spotube.core.paths
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect class Paths { expect class Paths() {
fun getApplicationCacheDirPath(): String fun getApplicationCacheDirPath(): String
fun getApplicationDataDirPath(): String fun getApplicationDataDirPath(): String
fun getUserDownloadsDirPath(): String fun getUserDownloadsDirPath(): String

View File

@ -29,6 +29,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
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 org.koin.core.component.KoinComponent
import java.awt.BorderLayout import java.awt.BorderLayout
@ -38,8 +40,6 @@ import java.awt.Image
import java.awt.SystemTray import java.awt.SystemTray
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.TrayIcon import java.awt.TrayIcon
import java.awt.event.FocusAdapter
import java.awt.event.FocusEvent
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.awt.event.WindowAdapter import java.awt.event.WindowAdapter
@ -50,10 +50,8 @@ import javax.swing.BoxLayout
import javax.swing.JCheckBoxMenuItem import javax.swing.JCheckBoxMenuItem
import javax.swing.JDialog import javax.swing.JDialog
import javax.swing.JLabel import javax.swing.JLabel
import javax.swing.JMenu
import javax.swing.JMenuItem import javax.swing.JMenuItem
import javax.swing.JPanel import javax.swing.JPanel
import javax.swing.JPopupMenu
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import javax.swing.UIManager import javax.swing.UIManager
@ -71,26 +69,87 @@ class SystemTrayService(
private var menuDialog: TrayMenuDialog? = null private var menuDialog: TrayMenuDialog? = null
private var windowVisible: Boolean = true private var windowVisible: Boolean = true
private var onToggleWindowVisibility: (() -> Unit)? = null
private var onExit: (() -> Unit)? = null
@Volatile private var onToggleWindowVisibility: () -> Unit = {}
private var started = false private var onExit: () -> Unit = {}
private val volumeStep = 0.05f 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, onToggleWindowVisibility: () -> Unit,
onExit: () -> Unit, onExit: () -> Unit,
) { ) {
this.onToggleWindowVisibility = onToggleWindowVisibility this.onToggleWindowVisibility = onToggleWindowVisibility
this.onExit = onExit this.onExit = onExit
}
if (started) { fun hideWindow() {
return if (windowVisible) {
windowVisible = false
onToggleWindowVisibility()
} }
started = true }
fun showWindow() {
if (!windowVisible) {
windowVisible = true
onToggleWindowVisibility()
}
}
private fun ensureTrayCreated() {
if (trayIcon != null) return
if (!SystemTray.isSupported()) { if (!SystemTray.isSupported()) {
logger.w { "System tray is not supported on this platform" } logger.w { "System tray is not supported on this platform" }
return return
@ -104,25 +163,39 @@ class SystemTrayService(
icon.addMouseListener(object : MouseAdapter() { icon.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { override fun mouseClicked(e: MouseEvent) {
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) { if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) {
onToggleWindowVisibility.invoke() if (windowVisible) hideWindow() else showWindow()
} else if (e.button == MouseEvent.BUTTON3) { } else if (e.button == MouseEvent.BUTTON3) {
showMenuDialog(icon, e.xOnScreen, e.yOnScreen) showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
} }
} }
}) })
val systemTray = SystemTray.getSystemTray() SystemTray.getSystemTray().add(icon)
systemTray.add(icon) trayIcon = icon
this.trayIcon = icon logger.i { "System tray created" }
logger.i { "System tray initialized" }
observeState()
} catch (e: Exception) { } 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) { private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
val dialog = menuDialog val dialog = menuDialog
if (dialog != null && dialog.isVisible) { if (dialog != null && dialog.isVisible) {
@ -135,7 +208,6 @@ class SystemTrayService(
isUndecorated = true isUndecorated = true
isAlwaysOnTop = true isAlwaysOnTop = true
focusableWindowState = true focusableWindowState = true
setAutoRequestFocus(true)
background = Color(0, 0, 0, 0) background = Color(0, 0, 0, 0)
} }
@ -190,7 +262,7 @@ class SystemTrayService(
panel.add(createSeparator()) panel.add(createSeparator())
panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") { panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") {
onToggleWindowVisibility?.invoke() if (windowVisible) hideWindow() else showWindow()
}) })
panel.add(createSeparator()) panel.add(createSeparator())
@ -219,18 +291,12 @@ class SystemTrayService(
val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL) val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL)
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
listOf(loopNone, loopOne, loopAll).forEach { item -> listOf(loopNone, loopOne, loopAll).forEach { item ->
item.isOpaque = true item.isOpaque = true
val normalBackground = item.background val normalBg = item.background
item.addMouseListener(object : MouseAdapter() { item.addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) { override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground }
item.background = hoverBackground override fun mouseExited(e: MouseEvent) { item.background = normalBg }
}
override fun mouseExited(e: MouseEvent) {
item.background = normalBackground
}
}) })
} }
@ -279,53 +345,16 @@ class SystemTrayService(
}) })
panel.add(createSeparator()) panel.add(createSeparator())
panel.add(createMenuItem("Exit") { onExit?.invoke() }) panel.add(createMenuItem("Exit") { onExit() })
panel.revalidate() panel.revalidate()
panel.repaint() panel.repaint()
dialog.pack() 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) { private fun updateTooltip(state: TrayState) {
val icon = trayIcon ?: return
val media = state.mediaItem val media = state.mediaItem
icon.toolTip = if (media != null) { trayIcon?.toolTip = if (media != null) {
"Spotube - ${media.title} by ${media.artist}" "Spotube - ${media.title} by ${media.artist}"
} else { } else {
"Spotube" "Spotube"
@ -338,20 +367,11 @@ class SystemTrayService(
isOpaque = true isOpaque = true
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
addActionListener { action() } addActionListener { action() }
val normalBg = background
val normalBackground = background val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
addMouseListener(object : MouseAdapter() { addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) { override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
if (isEnabled) { override fun mouseExited(e: MouseEvent) { background = normalBg }
background = hoverBackground
}
}
override fun mouseExited(e: MouseEvent) {
background = normalBackground
}
}) })
} }
} }
@ -361,20 +381,11 @@ class SystemTrayService(
isOpaque = true isOpaque = true
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
addActionListener { action() } addActionListener { action() }
val normalBg = background
val normalBackground = background val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
addMouseListener(object : MouseAdapter() { addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) { override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
if (isEnabled) { override fun mouseExited(e: MouseEvent) { background = normalBg }
background = hoverBackground
}
}
override fun mouseExited(e: MouseEvent) {
background = normalBackground
}
}) })
} }
} }
@ -396,26 +407,7 @@ class SystemTrayService(
} }
private fun createSeparator(): javax.swing.JSeparator { private fun createSeparator(): javax.swing.JSeparator {
return javax.swing.JSeparator().apply { return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) }
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
} }
private fun createTrayIconImage(): Image { private fun createTrayIconImage(): Image {

View File

@ -84,52 +84,34 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
val windowState = LocalWindowState.current val windowState = LocalWindowState.current
val applicationScope = LocalApplicationScope.current val applicationScope = LocalApplicationScope.current
val settingsProvider = koinInject<SettingsProvider>() val settingsProvider = koinInject<SettingsProvider>()
val systemTrayService = koinInject<SystemTrayService>() val trayService = koinInject<SystemTrayService>()
val settings by settingsProvider.settingsState.collectAsState(initial = null) val settings by settingsProvider.settingsState.collectAsState(initial = null)
val minimizeToTray = settings?.minimizeToTray ?: false
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
IconButton( IconButton(onClick = { windowState.isMinimized = true }) {
onClick = { Icon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp))
windowState.isMinimized = true
}) {
Icon(
Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)
)
} }
IconButton( IconButton(onClick = {
onClick = { windowState.placement = if (windowState.placement == WindowPlacement.Maximized)
windowState.placement = if (windowState.placement == WindowPlacement.Maximized) { WindowPlacement.Floating else WindowPlacement.Maximized
WindowPlacement.Floating }) {
} else {
WindowPlacement.Maximized
}
}) {
if (windowState.placement == WindowPlacement.Floating) { if (windowState.placement == WindowPlacement.Floating) {
Icon( Icon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp))
Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)
)
} else { } else {
Icon( Icon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp))
Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)
)
} }
} }
IconButton( IconButton(onClick = {
onClick = { if (settings?.minimizeToTray == true) {
if (minimizeToTray) { trayService.hideWindow()
windowState.isMinimized = true } else {
systemTrayService.setWindowVisible(false) applicationScope.exitApplication()
} else { }
applicationScope.exitApplication() }) {
} Icon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp))
}) {
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.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.ExperimentalComposeUiApi
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
@ -68,22 +71,33 @@ fun main() {
val settings by settingsProvider.settingsState.collectAsState(initial = null) val settings by settingsProvider.settingsState.collectAsState(initial = null)
val minimizeToTray = settings?.minimizeToTray ?: false 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( Window(
state = windowState, state = windowState,
onCloseRequest = { onCloseRequest = {
if (minimizeToTray) { if (minimizeToTray) {
windowState.isMinimized = true isWindowVisible = false
systemTrayService.setWindowVisible(false)
} else { } else {
trayService.close()
appScope.cancel() appScope.cancel()
exitApplication() exitApplication()
} }
}, },
title = "Spotube", title = "Spotube",
decoration = WindowDecoration.Undecorated(), decoration = WindowDecoration.Undecorated(),
visible = true, visible = isWindowVisible,
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalApplicationScope provides this@application, LocalApplicationScope provides this@application,
@ -93,17 +107,5 @@ fun main() {
App() App()
} }
} }
systemTrayService.start(
onToggleWindowVisibility = {
windowState.isMinimized = !windowState.isMinimized
systemTrayService.setWindowVisible(!windowState.isMinimized)
},
onExit = {
systemTrayService.close()
appScope.cancel()
exitApplication()
}
)
} }
} }