Compare commits

...

7 Commits

Author SHA1 Message Date
dependabot[bot]
ed277f4d02
Merge eb30f98056 into 8fcd35eb9b 2026-07-21 16:40:09 +00:00
Kingkor Roy Tirtho
8fcd35eb9b feat: add SSH debug session setup on failure for improved troubleshooting 2026-07-21 22:38:51 +06:00
Kingkor Roy Tirtho
019bbfae14 feat: add hover effect to menu items in System Tray for improved user experience 2026-07-21 22:22:12 +06:00
Kingkor Roy Tirtho
e5993b8ba1 fix: enhance Discord RPC connection management with cooldown and error handling 2026-07-21 14:34:45 +06:00
Kingkor Roy Tirtho
032d9d174b feat: implement System Tray integration for window management and media controls 2026-07-21 14:28:21 +06:00
Kingkor Roy Tirtho
74c345d53c feat: add Discord Rich Presence integration for enhanced media playback visibility 2026-07-21 13:59:07 +06:00
dependabot[bot]
eb30f98056
chore(deps): bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 05:08:47 +00:00
10 changed files with 967 additions and 16 deletions

View File

@ -16,6 +16,11 @@ on:
required: false
type: boolean
default: false
debug_ssh:
description: 'Enable SSH debug session on failure (tmate)'
required: false
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -101,7 +106,7 @@ jobs:
- uses: actions/checkout@v4
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
@ -138,6 +143,13 @@ jobs:
- name: Assemble Release
run: ./gradlew :composeApp:assembleRelease
- name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
timeout-minutes: 15
- name: Rename APK
run: |
APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1)
@ -157,7 +169,7 @@ jobs:
- uses: actions/checkout@v4
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
@ -185,6 +197,13 @@ jobs:
- name: Package Linux
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm :composeApp:packageReleaseAppImage
- name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
timeout-minutes: 15
- name: Rename Linux artifacts
run: |
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
@ -220,7 +239,7 @@ jobs:
- uses: actions/checkout@v4
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
@ -240,6 +259,13 @@ jobs:
- name: Package EXE
run: ./gradlew :composeApp:packageReleaseExe
- name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
timeout-minutes: 15
- name: Rename Windows artifacts
shell: pwsh
run: |
@ -260,7 +286,7 @@ jobs:
- uses: actions/checkout@v4
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
@ -283,6 +309,13 @@ jobs:
- name: Package DMG
run: ./gradlew :composeApp:packageReleaseDmg
- name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
timeout-minutes: 15
- name: Rename macOS artifact
run: |
DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1)
@ -325,7 +358,7 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
- name: Generate release notes
run: |

80
composeApp/Cargo.lock generated
View File

@ -41,7 +41,7 @@ dependencies = [
"rustc-hash",
"serde",
"serde_derive",
"syn",
"syn 2.0.119",
]
[[package]]
@ -131,6 +131,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
name = "compose-app"
version = "0.1.0"
dependencies = [
"discord-rich-presence",
"lofty",
"uniffi",
]
@ -150,6 +151,21 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "discord-rich-presence"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"thiserror",
"uuid",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -191,6 +207,17 @@ dependencies = [
"autocfg",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@ -282,7 +309,7 @@ checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@ -416,7 +443,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@ -456,7 +483,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@ -472,6 +499,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@ -507,6 +545,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@ -514,7 +563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
@ -546,7 +595,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@ -626,7 +675,7 @@ dependencies = [
"indexmap",
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@ -641,7 +690,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
"syn",
"syn 2.0.119",
"toml",
"uniffi_meta",
]
@ -683,6 +732,21 @@ dependencies = [
"weedle2",
]
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "weedle2"
version = "5.0.0"

View File

@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
uniffi = "0.29.4"
lofty = "0.24.0"
discord-rich-presence = "1.1.0"
[lib]
crate-type = ["cdylib", "staticlib"]

View File

@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
import dev.krtirtho.spotube.core.db.Database
import dev.krtirtho.spotube.core.discord.DiscordRpcService
import dev.krtirtho.spotube.core.navigation.navigationModule
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
@ -172,6 +173,8 @@ val sharedModules = module {
DeviceAudioPlayerQueue(get(), get(), get(), get())
}
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
factory { (tag: String?) ->
if (tag != null) {
Logger.withTag(tag)

View File

@ -0,0 +1,215 @@
/*
* 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.discord
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import uniffi.compose_app.DiscordRpcClient
import kotlin.time.Clock
class DiscordRpcService(
private val audioPlayer: AudioPlayerInterface,
private val settingsProvider: SettingsProvider,
) : KoinComponent, AutoCloseable {
private val logger by injectLogger<DiscordRpcService>()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var observerJob: Job? = null
private val clientId = "1176718791388975124"
private var rpcClient: DiscordRpcClient? = null
private var lastConnectionAttempt: Long = 0
private var connectionFailed: Boolean = false
private val connectionCooldownMs = 30_000L // 30 seconds cooldown between connection attempts
init {
start()
}
private fun start() {
logger.d { "Starting Discord RPC service" }
observerJob?.cancel()
observerJob = scope.launch {
combine(
settingsProvider.settingsState,
audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow,
audioPlayer.positionFlow,
audioPlayer.durationFlow,
) { settings, playerState, mediaItem, position, duration ->
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
}.collect { state ->
handleStateChange(state)
}
}
}
private fun handleStateChange(state: DiscordRpcState) {
if (!state.enabled) {
disconnectClient()
return
}
when (state.playerState) {
PlayerState.PLAYING -> {
val mediaItem = state.mediaItem ?: return
ensureConnected()
updatePresence(
title = mediaItem.title,
artist = mediaItem.artist,
album = mediaItem.album,
coverUrl = mediaItem.coverURL,
positionMs = state.position.inWholeMilliseconds,
durationMs = state.duration.inWholeMilliseconds,
)
}
PlayerState.PAUSED -> {
val mediaItem = state.mediaItem ?: return
ensureConnected()
updatePresence(
title = mediaItem.title,
artist = mediaItem.artist,
album = mediaItem.album,
coverUrl = mediaItem.coverURL,
positionMs = state.position.inWholeMilliseconds,
durationMs = state.duration.inWholeMilliseconds,
)
}
PlayerState.IDLE, PlayerState.COMPLETED -> {
clearPresence()
}
PlayerState.BUFFERING, PlayerState.READY -> {
}
}
}
private fun ensureConnected() {
val client = rpcClient
if (client != null) {
try {
if (client.isConnected()) return
logger.w { "Discord RPC connection lost, will retry after cooldown" }
client.disconnect()
rpcClient = null
connectionFailed = true
lastConnectionAttempt = currentTimeMillis()
return
} catch (e: Exception) {
logger.w { "Discord RPC connection check failed, will retry after cooldown" }
rpcClient = null
connectionFailed = true
lastConnectionAttempt = currentTimeMillis()
return
}
}
// Check cooldown period
val now = currentTimeMillis()
if (connectionFailed && (now - lastConnectionAttempt) < connectionCooldownMs) {
return
}
lastConnectionAttempt = now
try {
val newClient = DiscordRpcClient(clientId)
newClient.connect()
rpcClient = newClient
connectionFailed = false
logger.i { "Connected to Discord RPC" }
} catch (e: Exception) {
connectionFailed = true
logger.d { "Discord not available, will retry in ${connectionCooldownMs / 1000}s" }
}
}
private fun currentTimeMillis(): Long {
return Clock.System.now().toEpochMilliseconds()
}
private fun updatePresence(
title: String,
artist: String,
album: String,
coverUrl: String,
positionMs: Long,
durationMs: Long,
) {
val client = rpcClient ?: return
try {
client.updatePresence(
title = title,
artist = artist,
album = album,
coverUrl = coverUrl,
positionMs = positionMs,
durationMs = durationMs,
)
} catch (e: Exception) {
rpcClient = null
connectionFailed = true
lastConnectionAttempt = currentTimeMillis()
logger.d { "Discord RPC update failed, will retry after cooldown" }
}
}
private fun clearPresence() {
val client = rpcClient ?: return
try {
client.clearPresence()
} catch (e: Exception) {
rpcClient = null
connectionFailed = true
lastConnectionAttempt = currentTimeMillis()
logger.d { "Discord RPC clear failed, will retry after cooldown" }
}
}
private fun disconnectClient() {
try {
rpcClient?.disconnect()
rpcClient = null
logger.d { "Disconnected from Discord RPC" }
} catch (e: Exception) {
logger.e(e) { "Failed to disconnect from Discord RPC" }
}
}
override fun close() {
observerJob?.cancel()
disconnectClient()
}
private data class DiscordRpcState(
val enabled: Boolean,
val playerState: PlayerState,
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
val position: kotlin.time.Duration,
val duration: kotlin.time.Duration,
)
}

View File

@ -1,9 +1,13 @@
use discord_rich_presence::activity::{Activity, ActivityType, Assets, Timestamps};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use lofty::config::WriteOptions;
use lofty::file::{AudioFile, FileType, TaggedFileExt};
use lofty::picture::Picture;
use lofty::probe::Probe;
use lofty::tag::{Accessor, Tag, TagExt};
use std::path::Path;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(uniffi::Record)]
pub struct AudioMetadata {
@ -157,4 +161,138 @@ fn write_audio_metadata(
Ok(())
}
#[derive(Debug, uniffi::Error)]
pub enum DiscordRpcError {
ConnectionError { reason: String },
UpdateError { reason: String },
NotConnected,
}
impl std::fmt::Display for DiscordRpcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiscordRpcError::ConnectionError { reason } => write!(f, "Connection error: {}", reason),
DiscordRpcError::UpdateError { reason } => write!(f, "Update error: {}", reason),
DiscordRpcError::NotConnected => write!(f, "Not connected"),
}
}
}
#[derive(uniffi::Object)]
pub struct DiscordRpcClient {
client_id: String,
client: Mutex<Option<DiscordIpcClient>>,
}
#[uniffi::export]
impl DiscordRpcClient {
#[uniffi::constructor]
pub fn new(client_id: String) -> Self {
Self {
client_id,
client: Mutex::new(None),
}
}
pub fn connect(&self) -> Result<(), DiscordRpcError> {
let mut client_guard = self.client.lock().unwrap();
if client_guard.is_some() {
return Ok(());
}
let mut client = DiscordIpcClient::new(&self.client_id);
client
.connect()
.map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?;
*client_guard = Some(client);
Ok(())
}
pub fn disconnect(&self) -> Result<(), DiscordRpcError> {
let mut client_guard = self.client.lock().unwrap();
if let Some(mut client) = client_guard.take() {
client
.close()
.map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?;
}
Ok(())
}
pub fn update_presence(
&self,
title: String,
artist: String,
album: String,
cover_url: String,
position_ms: i64,
duration_ms: i64,
) -> Result<(), DiscordRpcError> {
let mut client_guard = self.client.lock().unwrap();
let client = client_guard
.as_mut()
.ok_or(DiscordRpcError::NotConnected)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let start = now - (position_ms / 1000);
let end = start + (duration_ms / 1000);
let details = if title.is_empty() {
"Unknown Track".to_string()
} else {
title
};
let state = if artist.is_empty() {
"Unknown Artist".to_string()
} else {
format!("by {}", artist)
};
let mut activity = Activity::new()
.details(&details)
.state(&state)
.timestamps(Timestamps::new().start(start).end(end))
.activity_type(ActivityType::Listening);
if !album.is_empty() {
let mut assets = Assets::new().large_text(&album);
if !cover_url.is_empty() {
assets = assets.large_image(&cover_url);
}
activity = activity.assets(assets);
} else if !cover_url.is_empty() {
activity = activity.assets(Assets::new().large_image(&cover_url));
}
client
.set_activity(activity)
.map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?;
Ok(())
}
pub fn clear_presence(&self) -> Result<(), DiscordRpcError> {
let mut client_guard = self.client.lock().unwrap();
let client = client_guard
.as_mut()
.ok_or(DiscordRpcError::NotConnected)?;
client
.clear_activity()
.map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?;
Ok(())
}
pub fn is_connected(&self) -> bool {
self.client.lock().unwrap().is_some()
}
}
uniffi::setup_scaffolding!();

View File

@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.share.JvmShareService
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.systemtray.SystemTrayService
import dev.krtirtho.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService
import org.koin.core.module.dsl.singleOf
@ -32,4 +33,5 @@ actual val platformModules = module {
single<AudioPlayerInterface> { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
single<ShareService> { JvmShareService() }
single { SystemTrayService(get(), get(), get(), get()) }
}

View File

@ -0,0 +1,448 @@
/*
* 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 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.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.flow.combine
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.FocusAdapter
import java.awt.event.FocusEvent
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.JMenu
import javax.swing.JMenuItem
import javax.swing.JPanel
import javax.swing.JPopupMenu
import javax.swing.SwingUtilities
import javax.swing.UIManager
class SystemTrayService(
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val settingsProvider: SettingsProvider,
private val savedTracksRepository: SavedTracksRepository,
) : KoinComponent, 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 var onToggleWindowVisibility: (() -> Unit)? = null
private var onExit: (() -> Unit)? = null
@Volatile
private var started = false
private val volumeStep = 0.05f
fun start(
onToggleWindowVisibility: () -> Unit,
onExit: () -> Unit,
) {
this.onToggleWindowVisibility = onToggleWindowVisibility
this.onExit = onExit
if (started) {
return
}
started = true
if (!SystemTray.isSupported()) {
logger.w { "System tray is not supported on this platform" }
return
}
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) {
onToggleWindowVisibility.invoke()
} else if (e.button == MouseEvent.BUTTON3) {
showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
}
}
})
val systemTray = SystemTray.getSystemTray()
systemTray.add(icon)
this.trayIcon = icon
logger.i { "System tray initialized" }
observeState()
} catch (e: Exception) {
logger.e(e) { "Failed to initialize system tray" }
}
}
}
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
setAutoRequestFocus(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") {
onToggleWindowVisibility?.invoke()
})
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 normalBackground = item.background
item.addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
item.background = hoverBackground
}
override fun mouseExited(e: MouseEvent) {
item.background = normalBackground
}
})
}
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?.invoke() })
panel.revalidate()
panel.repaint()
dialog.pack()
}
@Volatile
private var currentState: TrayState? = null
private fun observeState() {
scope.launch {
combine(
audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow,
audioPlayer.loopStateFlow,
audioPlayer.shuffleModeFlow,
audioPlayer.volumeFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
) { values ->
TrayState(
playerState = values[0] as PlayerState,
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
loopState = values[2] as LoopState,
shuffleEnabled = values[3] as Boolean,
volume = values[4] as Float,
currentEntry = values[5] as QueueEntry?,
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
)
}.collect { state ->
currentState = state
SwingUtilities.invokeLater {
updateTooltip(state)
menuDialog?.let { dialog ->
val panel = dialog.contentPane.getComponent(0) as? JPanel
if (panel != null) rebuildMenuPanel(panel, dialog)
}
}
}
}
}
private fun updateTooltip(state: TrayState) {
val icon = trayIcon ?: return
val media = state.mediaItem
icon.toolTip = if (media != null) {
"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 normalBackground = background
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
if (isEnabled) {
background = hoverBackground
}
}
override fun mouseExited(e: MouseEvent) {
background = normalBackground
}
})
}
}
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 normalBackground = background
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
if (isEnabled) {
background = hoverBackground
}
}
override fun mouseExited(e: MouseEvent) {
background = normalBackground
}
})
}
}
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)
}
}
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 {
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 mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
val loopState: LoopState,
val shuffleEnabled: Boolean,
val volume: Float,
val currentEntry: QueueEntry?,
val savedTrackIds: Set<String>,
)
}

View File

@ -31,15 +31,20 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPlacement
import dev.krtirtho.spotube.core.systemtray.SystemTrayService
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
import dev.krtirtho.spotube.resources.iconsax.FluentMaximize
import dev.krtirtho.spotube.resources.iconsax.FluentMinus
import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@ -78,6 +83,10 @@ actual fun ApplicationMainBar(
private fun WindowButtons(modifier: Modifier = Modifier) {
val windowState = LocalWindowState.current
val applicationScope = LocalApplicationScope.current
val settingsProvider = koinInject<SettingsProvider>()
val systemTrayService = koinInject<SystemTrayService>()
val settings by settingsProvider.settingsState.collectAsState(initial = null)
val minimizeToTray = settings?.minimizeToTray ?: false
Row(
modifier = modifier,
@ -111,7 +120,12 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
}
IconButton(
onClick = {
applicationScope.exitApplication()
if (minimizeToTray) {
windowState.isMinimized = true
systemTrayService.setWindowVisible(false)
} else {
applicationScope.exitApplication()
}
}) {
Icon(
Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)

View File

@ -18,6 +18,8 @@
package dev.krtirtho.spotube
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
@ -27,9 +29,11 @@ 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.SystemTrayService
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowState
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import io.github.vinceglb.filekit.FileKit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -42,6 +46,11 @@ object KoinPathsProvider : KoinComponent {
val paths: Paths get() = get()
}
private object KoinServicesProvider : KoinComponent {
val systemTrayService: SystemTrayService get() = get()
val settingsProvider: SettingsProvider get() = get()
}
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
FileKit.init(appId = "dev.krtirtho.spotube")
@ -55,14 +64,26 @@ fun main() {
height = 720.dp
)
val settingsProvider = KoinServicesProvider.settingsProvider
val settings by settingsProvider.settingsState.collectAsState(initial = null)
val minimizeToTray = settings?.minimizeToTray ?: false
val systemTrayService = KoinServicesProvider.systemTrayService
Window(
state = windowState,
onCloseRequest = {
appScope.cancel()
exitApplication()
if (minimizeToTray) {
windowState.isMinimized = true
systemTrayService.setWindowVisible(false)
} else {
appScope.cancel()
exitApplication()
}
},
title = "Spotube",
decoration = WindowDecoration.Undecorated(),
visible = true,
) {
CompositionLocalProvider(
LocalApplicationScope provides this@application,
@ -72,5 +93,17 @@ fun main() {
App()
}
}
systemTrayService.start(
onToggleWindowVisibility = {
windowState.isMinimized = !windowState.isMinimized
systemTrayService.setWindowVisible(!windowState.isMinimized)
},
onExit = {
systemTrayService.close()
appScope.cancel()
exitApplication()
}
)
}
}