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.core.runtime)
|
||||||
implementation(libs.nucleus.nucleus.application)
|
implementation(libs.nucleus.nucleus.application)
|
||||||
implementation(libs.nucleus.decorated.window.tao)
|
implementation(libs.nucleus.decorated.window.tao)
|
||||||
|
implementation(libs.compose.native.tray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,5 +33,5 @@ actual val platformModules = module {
|
|||||||
single<AudioPlayerInterface> { AudioPlayer(Unit) }
|
single<AudioPlayerInterface> { AudioPlayer(Unit) }
|
||||||
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
||||||
single<ShareService> { JvmShareService() }
|
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.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
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.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
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.saved_tracks.SavedTracksRepository
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
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.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
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(
|
class SystemTrayService(
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val settingsProvider: SettingsProvider,
|
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
) : KoinComponent, AutoCloseable {
|
) : AutoCloseable {
|
||||||
|
|
||||||
private val logger by injectLogger<SystemTrayService>()
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
private var trayIcon: TrayIcon? = null
|
private val volumeStep = 0.05f
|
||||||
private var menuDialog: TrayMenuDialog? = null
|
|
||||||
|
|
||||||
private var windowVisible: Boolean = true
|
|
||||||
|
|
||||||
private var onToggleWindowVisibility: () -> Unit = {}
|
private var onToggleWindowVisibility: () -> Unit = {}
|
||||||
private var onExit: () -> Unit = {}
|
|
||||||
|
|
||||||
private val volumeStep = 0.05f
|
val state: StateFlow<TrayState> =
|
||||||
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 {
|
|
||||||
combine(
|
combine(
|
||||||
audioPlayer.playerStateFlow,
|
audioPlayer.playerStateFlow,
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
@ -106,335 +65,98 @@ class SystemTrayService(
|
|||||||
) { values ->
|
) { values ->
|
||||||
TrayState(
|
TrayState(
|
||||||
playerState = values[0] as PlayerState,
|
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,
|
loopState = values[2] as LoopState,
|
||||||
shuffleEnabled = values[3] as Boolean,
|
shuffleEnabled = values[3] as Boolean,
|
||||||
volume = values[4] as Float,
|
volume = values[4] as Float,
|
||||||
currentEntry = values[5] as QueueEntry?,
|
currentEntry = values[5] as QueueEntry?,
|
||||||
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
|
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
|
||||||
)
|
)
|
||||||
}.collect { state ->
|
}.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial)
|
||||||
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(
|
fun setCallbacks(onToggleWindowVisibility: () -> Unit) {
|
||||||
onToggleWindowVisibility: () -> Unit,
|
|
||||||
onExit: () -> Unit,
|
|
||||||
) {
|
|
||||||
this.onToggleWindowVisibility = onToggleWindowVisibility
|
this.onToggleWindowVisibility = onToggleWindowVisibility
|
||||||
this.onExit = onExit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hideWindow() {
|
fun hideWindow() = onToggleWindowVisibility()
|
||||||
if (windowVisible) {
|
|
||||||
windowVisible = false
|
fun togglePlayPause() {
|
||||||
onToggleWindowVisibility()
|
scope.launch {
|
||||||
|
if (state.value.playerState == PlayerState.PLAYING) audioPlayer.pause() else audioPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showWindow() {
|
fun skipToNext() {
|
||||||
if (!windowVisible) {
|
scope.launch { audioPlayer.skipToNext() }
|
||||||
windowVisible = true
|
|
||||||
onToggleWindowVisibility()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureTrayCreated() {
|
fun skipToPrevious() {
|
||||||
if (trayIcon != null) return
|
scope.launch { audioPlayer.skipToPrevious() }
|
||||||
if (!SystemTray.isSupported()) {
|
|
||||||
logger.w { "System tray is not supported on this platform" }
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SwingUtilities.invokeLater {
|
fun setShuffle(enabled: Boolean) {
|
||||||
try {
|
scope.launch { audioPlayer.shuffle(enabled) }
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeTray() {
|
fun setLoop(loopState: LoopState) {
|
||||||
menuDialog?.dispose()
|
scope.launch { audioPlayer.loop(loopState) }
|
||||||
menuDialog = null
|
}
|
||||||
trayIcon?.let {
|
|
||||||
try {
|
fun increaseVolume() {
|
||||||
SystemTray.getSystemTray().remove(it)
|
scope.launch { audioPlayer.setVolume((state.value.volume + volumeStep).coerceAtMost(1f)) }
|
||||||
} catch (e: Exception) {
|
}
|
||||||
logger.e(e) { "Failed to remove tray icon" }
|
|
||||||
|
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() {
|
override fun close() {
|
||||||
removeTray()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
|
data class TrayState(
|
||||||
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(
|
|
||||||
val playerState: PlayerState,
|
val playerState: PlayerState,
|
||||||
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
val mediaItem: MediaItem?,
|
||||||
val loopState: LoopState,
|
val loopState: LoopState,
|
||||||
val shuffleEnabled: Boolean,
|
val shuffleEnabled: Boolean,
|
||||||
val volume: Float,
|
val volume: Float,
|
||||||
val currentEntry: QueueEntry?,
|
val currentEntry: QueueEntry?,
|
||||||
val savedTrackIds: Set<String>,
|
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.di.initKoin
|
||||||
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
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.systemtray.SystemTrayService
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
|
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
|
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
|
||||||
@ -75,14 +76,22 @@ fun main() {
|
|||||||
|
|
||||||
var isWindowVisible by remember { mutableStateOf(true) }
|
var isWindowVisible by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
trayService.setCallbacks(
|
val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }
|
||||||
onToggleWindowVisibility = { isWindowVisible = !isWindowVisible },
|
val onExit = {
|
||||||
onExit = {
|
|
||||||
trayService.close()
|
trayService.close()
|
||||||
appScope.cancel()
|
appScope.cancel()
|
||||||
exitApplication()
|
exitApplication()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trayService.setCallbacks(onToggleWindowVisibility)
|
||||||
|
|
||||||
|
if (minimizeToTray) {
|
||||||
|
SystemTray(
|
||||||
|
isWindowVisible = isWindowVisible,
|
||||||
|
onToggleWindowVisibility = onToggleWindowVisibility,
|
||||||
|
onExit = onExit,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
DecoratedWindow(
|
DecoratedWindow(
|
||||||
state = windowState,
|
state = windowState,
|
||||||
|
|||||||
@ -58,6 +58,7 @@ runner = "1.7.0"
|
|||||||
core = "1.7.0"
|
core = "1.7.0"
|
||||||
filekit = "0.14.2"
|
filekit = "0.14.2"
|
||||||
compose-webview = "1.0.1"
|
compose-webview = "1.0.1"
|
||||||
|
composeNativeTray = "2.0.3"
|
||||||
koin = "4.2.2"
|
koin = "4.2.2"
|
||||||
multiplatform-nav3-ui = "1.1.1"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
compose-multiplatform-adaptive = "1.3.0-beta02"
|
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 = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
|
||||||
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", 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-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-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
|
||||||
koin-compose = { module = "io.insert-koin:koin-compose", 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" }
|
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user