feat: v1.30.0 — Discord RPC, offline bulk download, artist images, lazy loading, crossfade fix

- Discord Rich Presence (opt-in) — requested by @Bewenben (#49)
- Bulk offline download for playlists and artist discographies — requested by @Apollosport (#54)
- Offline Library filter tabs: All / Albums / Playlists / Discographies with artist grouping
- Artist images on Artists overview (opt-in, off by default) — reported by @Apollosport (#53)
- Image lazy loading via IntersectionObserver (300px margin) across all pages
- Fix: crossfade no longer triggers on manual track skip — reported by @netherguy4 (#35)
- Fix: playlist offline cache now stored as single entry (not per-album)
- Fix: image cache AbortController no longer blocks IDB writes
- Update toast: experimental auto-update hint + GH download link always visible (Win/Mac)
- Queue tech strip: genre removed
- Facebook theme: contrast, opaque badge/back button, queue tab labels
- "Save discography offline" label (was "Download discography")
- Fix: clearing empty playlists via updatePlaylist.view (Axios empty array workaround)
- starredOverrides propagated to AlbumDetail, Favorites, RandomMix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-03 14:53:19 +02:00
parent 651b3cb050
commit c365140870
29 changed files with 1099 additions and 338 deletions
+29 -6
View File
@@ -565,7 +565,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
"uuid 1.22.0",
]
[[package]]
@@ -986,6 +986,19 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "discord-rich-presence"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
dependencies = [
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"uuid 0.8.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -3470,9 +3483,10 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.28.0"
version = "1.30.0"
dependencies = [
"biquad",
"discord-rich-presence",
"md5",
"reqwest 0.12.28",
"rodio",
@@ -3996,7 +4010,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid",
"uuid 1.22.0",
]
[[package]]
@@ -4906,7 +4920,7 @@ dependencies = [
"thiserror 2.0.18",
"time",
"url",
"uuid",
"uuid 1.22.0",
"walkdir",
]
@@ -5191,7 +5205,7 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"url",
"urlpattern",
"uuid",
"uuid 1.22.0",
"walkdir",
]
@@ -5704,6 +5718,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[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 = "uuid"
version = "1.22.0"
@@ -6898,7 +6921,7 @@ dependencies = [
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"uuid 1.22.0",
"windows-sys 0.61.2",
"winnow 0.7.15",
"zbus_macros 5.14.0",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.28.0"
version = "1.30.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -40,3 +40,4 @@ tauri-plugin-window-state = "2.4.1"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
+3 -1
View File
@@ -1074,6 +1074,7 @@ pub async fn audio_play(
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -1137,7 +1138,8 @@ pub async fn audio_play(
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
// Measure how much audio Track A actually has left right now.
+101
View File
@@ -0,0 +1,101 @@
/// Discord Rich Presence integration.
///
/// To enable this feature:
/// 1. Go to https://discord.com/developers/applications and create an application.
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
/// (use the app icon from public/logo.png).
///
/// The commands silently no-op when Discord is not running or the App ID is wrong,
/// so the app always starts cleanly regardless of Discord availability.
use discord_rich_presence::{
activity::{Activity, Assets, Timestamps},
DiscordIpc, DiscordIpcClient,
};
use std::sync::Mutex;
const DISCORD_APP_ID: &str = "1489544859718258779";
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
impl DiscordState {
pub fn new() -> Self {
DiscordState(Mutex::new(None))
}
}
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
fn try_connect() -> Option<DiscordIpcClient> {
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
client.connect().ok()?;
Some(client)
}
/// Update the Discord Rich Presence activity.
///
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
/// the song/artist without a running timer.
#[tauri::command]
pub fn discord_update_presence(
state: tauri::State<DiscordState>,
title: String,
artist: String,
album: Option<String>,
elapsed_secs: Option<f64>,
) -> Result<(), String> {
let mut guard = state.0.lock().unwrap();
// (Re)connect lazily — handles the case where Discord starts after the app.
if guard.is_none() {
match try_connect() {
Some(client) => *guard = Some(client),
None => return Ok(()), // Discord not running — silently skip
}
}
let client = guard.as_mut().unwrap();
let state_text = format!("by {artist}");
let large_text = album.as_deref().unwrap_or("Psysonic");
let assets = Assets::new()
.large_image("psysonic")
.large_text(large_text);
let mut activity = Activity::new()
.details(&title)
.state(&state_text)
.assets(assets);
// Start timestamp: Discord auto-counts up from this point. We back-calculate
// it so the displayed elapsed time matches the actual playback position.
if let Some(elapsed) = elapsed_secs {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let start = now - elapsed.floor() as i64;
activity = activity.timestamps(Timestamps::new().start(start));
}
if client.set_activity(activity).is_err() {
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
// call re-connects.
*guard = None;
}
Ok(())
}
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.0.lock().unwrap();
if let Some(client) = guard.as_mut() {
if client.clear_activity().is_err() {
*guard = None;
}
}
Ok(())
}
+4
View File
@@ -2,6 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio;
mod discord;
use std::collections::HashMap;
use std::sync::Mutex;
@@ -340,6 +341,7 @@ pub fn run() {
tauri::Builder::default()
.manage(audio_engine)
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
@@ -538,6 +540,8 @@ pub fn run() {
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
discord::discord_update_presence,
discord::discord_clear_presence,
lastfm_request,
download_track_offline,
delete_offline_track,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.29.0",
"version": "1.30.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",