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
+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,