feat: implement system tray functionality with Compose Native Tray integration

This commit is contained in:
Kingkor Roy Tirtho 2026-08-14 21:41:09 +06:00
parent 94903dcc33
commit e0b9ab530d
6 changed files with 207 additions and 383 deletions

View File

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

View File

@ -33,5 +33,5 @@ actual val platformModules = module {
single<AudioPlayerInterface> { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
single<ShareService> { JvmShareService() }
single { SystemTrayService(get(), get(), get(), get()) }
single { SystemTrayService(get(), get(), get()) }
}

View File

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

View File

@ -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<SystemTrayService>()
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<TrayState> =
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<String>),
)
}.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<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,
) {
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<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(),
)
}
}
}

View File

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

View File

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