mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
feat: implement system tray functionality with Compose Native Tray integration
This commit is contained in:
parent
94903dcc33
commit
e0b9ab530d
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()) }
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
@ -20,81 +20,40 @@ 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
|
||||
|
||||
@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 {
|
||||
val state: StateFlow<TrayState> =
|
||||
combine(
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
@ -106,335 +65,98 @@ class SystemTrayService(
|
||||
) { values ->
|
||||
TrayState(
|
||||
playerState = values[0] as PlayerState,
|
||||
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||
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>),
|
||||
)
|
||||
}.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
SystemTray.getSystemTray().add(icon)
|
||||
trayIcon = icon
|
||||
logger.i { "System tray created" }
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to create system tray" }
|
||||
}
|
||||
}
|
||||
fun setShuffle(enabled: Boolean) {
|
||||
scope.launch { audioPlayer.shuffle(enabled) }
|
||||
}
|
||||
|
||||
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" }
|
||||
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))
|
||||
}
|
||||
}
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
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,
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user