mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
7 Commits
0f2cafbec4
...
94e394201d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e394201d | ||
|
|
8fcd35eb9b | ||
|
|
019bbfae14 | ||
|
|
e5993b8ba1 | ||
|
|
032d9d174b | ||
|
|
74c345d53c | ||
|
|
5e65508fed |
35
.github/workflows/spotube-release-binary.yml
vendored
35
.github/workflows/spotube-release-binary.yml
vendored
@ -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 }}
|
||||
@ -61,7 +66,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
uses: android-actions/setup-android@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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: |
|
||||
@ -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)
|
||||
|
||||
80
composeApp/Cargo.lock
generated
80
composeApp/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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!();
|
||||
|
||||
@ -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()) }
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user