feat: v1.29.0 — Radio fast-start, seek fix, OGG, error toasts, contributors

Incorporates PRs #38 (nisarg-78), #42 #43 #44 (JulianNymark) with fixes:

Audio / Playback
- Artist Radio starts immediately from fast getTopSongs (local library);
  getSimilarSongs2 (Last.fm) enriches queue in background — no more wait
- Fix seek audio glitch: EqualPowerFadeIn only resets to zero-gain on
  seeks to track start (<100 ms); all other seeks resume at unity gain
- OGG/Vorbis container support via symphonia-format-ogg (PR #42)
- Human-readable audio error messages in SizedDecoder (PR #44)
- Audio playback errors shown as themed toast notifications (PR #43)

Queue / Radio
- Infinite Queue: proactive load of 5 tracks (was 25) when ≤2 remain
- Radio: proactive reload at ≤2 remaining tracks, independent of
  Infinite Queue setting — radio no longer stops at last track
- Fix: clicking Start Radio multiple times no longer stacks duplicates
- Fix: Start Radio on artist keeps current song playing
- Manual tracks always appear before Radio, Radio before Auto-added
- Queue separators: "— Radio —" and "— Auto —" dividers
- Fix: radio proactive load now works even when songs lack artistId
  (uses currentRadioArtistId module var as fallback)

UI / UX
- Click synced lyrics lines to seek (PR #38)
- Volume scroll wheel on volume slider (PR #38)
- Lyrics: active / completed / upcoming visual states (PR #38)
- Shared toast utility (src/utils/toast.ts) replaces inline toast fn
- Auto-updater: relaunch_after_update Rust command exits first to
  release single-instance lock before spawning new process

About / Credits
- nisarg-78 and JulianNymark added to contributors (since v1.29.0)
- netherguy4 added as Special Thanks for feature ideas and feedback
- i18n: aboutSpecialThanksLabel in all 5 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-02 22:17:55 +02:00
parent 95283d792b
commit 7263d93d42
20 changed files with 6264 additions and 3900 deletions
+13
View File
@@ -4524,6 +4524,7 @@ dependencies = [
"symphonia-codec-vorbis", "symphonia-codec-vorbis",
"symphonia-core", "symphonia-core",
"symphonia-format-isomp4", "symphonia-format-isomp4",
"symphonia-format-ogg",
"symphonia-format-riff", "symphonia-format-riff",
"symphonia-metadata", "symphonia-metadata",
] ]
@@ -4620,6 +4621,18 @@ dependencies = [
"symphonia-utils-xiph", "symphonia-utils-xiph",
] ]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]] [[package]]
name = "symphonia-format-riff" name = "symphonia-format-riff"
version = "0.5.5" version = "0.5.5"
+1 -1
View File
@@ -31,7 +31,7 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] } rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "wav", "adpcm"] } symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", features = ["stream", "json"] } reqwest = { version = "0.12", features = ["stream", "json"] }
md5 = "0.7" md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] } tokio = { version = "1", features = ["rt", "time"] }
+28 -9
View File
@@ -229,9 +229,15 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
fn sample_rate(&self) -> u32 { self.inner.sample_rate() } fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() } fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// Restart the fade envelope after seeking (avoids a mid-song click if // For mid-track seeks: skip straight to unity gain so the new position
// the user seeks to the very beginning while a fade was in progress). // plays at full volume immediately — no audible fade-in glitch.
self.sample_count = 0; // For seeks to the very start (< 100 ms): keep the micro-fade to
// suppress any DC-offset click from the fresh decode.
if pos.as_millis() < 100 {
self.sample_count = 0;
} else {
self.sample_count = self.fade_samples;
}
self.inner.try_seek(pos) self.inner.try_seek(pos)
} }
} }
@@ -478,13 +484,20 @@ impl SizedDecoder {
let probed = symphonia::default::get_probe() let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default()) .format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("probe failed: {e}"))?; .map_err(|e| {
let hint_str = format_hint.unwrap_or("unknown");
if e.to_string().to_lowercase().contains("unsupported") {
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
} else {
format!("could not open audio stream (.{hint_str}): {e}")
}
})?;
let track = probed.format let track = probed.format
.tracks() .tracks()
.iter() .iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL) .find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| "no supported audio track".to_string())?; .ok_or_else(|| "no playable audio track found in file".to_string())?;
let track_id = track.id; let track_id = track.id;
let total_duration = track.codec_params.time_base let total_duration = track.codec_params.time_base
@@ -493,7 +506,13 @@ impl SizedDecoder {
let mut decoder = symphonia::default::get_codecs() let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default()) .make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| format!("codec init failed: {e}"))?; .map_err(|e| {
if e.to_string().to_lowercase().contains("unsupported") {
"unsupported codec: no decoder available for this audio format".to_string()
} else {
format!("failed to initialise audio decoder: {e}")
}
})?;
let mut format = probed.format; let mut format = probed.format;
@@ -505,7 +524,7 @@ impl SizedDecoder {
Err(symphonia::core::errors::Error::IoError(_)) => { Err(symphonia::core::errors::Error::IoError(_)) => {
break decoder.last_decoded(); break decoder.last_decoded();
} }
Err(e) => return Err(format!("first packet: {e}")), Err(e) => return Err(format!("could not read audio data: {e}")),
}; };
if packet.track_id() != track_id { continue; } if packet.track_id() != track_id { continue; }
match decoder.decode(&packet) { match decoder.decode(&packet) {
@@ -513,10 +532,10 @@ impl SizedDecoder {
Err(symphonia::core::errors::Error::DecodeError(_)) => { Err(symphonia::core::errors::Error::DecodeError(_)) => {
decode_errors += 1; decode_errors += 1;
if decode_errors > DECODE_MAX_RETRIES { if decode_errors > DECODE_MAX_RETRIES {
return Err("too many decode errors".into()); return Err("too many decode errors — file may be corrupt".into());
} }
} }
Err(e) => return Err(format!("decode: {e}")), Err(e) => return Err(format!("audio decode error: {e}")),
} }
}; };
+45
View File
@@ -30,6 +30,50 @@ fn exit_app(app_handle: tauri::AppHandle) {
app_handle.exit(0); app_handle.exit(0);
} }
/// Restart after an in-app update.
///
/// `relaunch()` from tauri-plugin-process spawns the new process while the old one
/// is still alive, so the single-instance plugin in the new process sees the old
/// socket and kills itself immediately (endless loop).
///
/// This command instead:
/// 1. Spawns a shell snippet that waits ~1.5 s and then opens the app again —
/// by then the old process and its single-instance socket are fully gone.
/// 2. Calls `app.exit(0)` directly, which bypasses `on_window_event`
/// prevent_close() and releases the single-instance lock immediately.
#[tauri::command]
fn relaunch_after_update(app: tauri::AppHandle) {
let exe = std::env::current_exe().unwrap_or_default();
#[cfg(target_os = "windows")]
{
let exe_str = exe.to_string_lossy().to_string().replace('\'', "''");
let script = format!(
"Start-Sleep -Milliseconds 1500; Start-Process '{exe_str}'"
);
let _ = std::process::Command::new("powershell")
.args(["-WindowStyle", "Hidden", "-NonInteractive", "-Command", &script])
.spawn();
}
#[cfg(target_os = "macos")]
{
// exe lives at Psysonic.app/Contents/MacOS/psysonic — walk up to the bundle.
let bundle = exe
.parent() // MacOS/
.and_then(|p| p.parent()) // Contents/
.and_then(|p| p.parent()); // Psysonic.app
if let Some(bundle_path) = bundle {
let escaped = bundle_path.to_string_lossy().replace('"', "\\\"");
let _ = std::process::Command::new("sh")
.args(["-c", &format!("sleep 1.5 && open \"{escaped}\"")])
.spawn();
}
}
app.exit(0);
}
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions. /// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
/// `params` is a list of [key, value] pairs (method must be included). /// `params` is a list of [key, value] pairs (method must be included).
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made. /// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
@@ -498,6 +542,7 @@ pub fn run() {
download_track_offline, download_track_offline,
delete_offline_track, delete_offline_track,
get_offline_cache_size, get_offline_cache_size,
relaunch_after_update,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running Psysonic"); .expect("error while running Psysonic");
+1 -14
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { showToast } from './utils/toast';
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
@@ -433,20 +434,6 @@ export default function App() {
const handleExport = async (since: number) => { const handleExport = async (since: number) => {
setExportPickerOpen(false); setExportPickerOpen(false);
const showToast = (text: string) => {
const toast = document.createElement('div');
toast.textContent = text;
toast.style.cssText = `
position:fixed; bottom:100px; left:50%; transform:translateX(-50%);
background:#24273a; color:#cad3f5; border:1px solid #363a4f;
padding:10px 20px; border-radius:10px; font-size:14px;
z-index:999999; pointer-events:none;
box-shadow:0 4px 24px rgba(0,0,0,0.5);
white-space:nowrap;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
};
try { try {
const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums'); const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums');
const result = await exportNewAlbumsImage(since); const result = await exportNewAlbumsImage(since);
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater'; import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process'; import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import { RefreshCw, Download, X } from 'lucide-react'; import { RefreshCw, Download, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -81,7 +81,7 @@ export default function AppUpdater() {
setState({ phase: 'installing' }); setState({ phase: 'installing' });
} }
}); });
await relaunch(); await invoke('relaunch_after_update');
} catch (e) { } catch (e) {
console.error('Update failed', e); console.error('Update failed', e);
setState({ phase: 'available', version: savedVersion, update }); setState({ phase: 'available', version: savedVersion, update });
+47 -7
View File
@@ -217,20 +217,60 @@ export default function ContextMenu() {
} else { } else {
playTrack(seedTrack, [seedTrack]); playTrack(seedTrack, [seedTrack]);
} }
// Load radio queue in background // Load radio queue in background — enqueueRadio replaces any pending radio
// tracks so clicking "Start Radio" again never stacks duplicate batches.
try { try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
const radioTracks = [...top, ...similar].map(songToTrack).filter(t => t.id !== seedTrack.id); const radioTracks = [...top, ...similar]
if (radioTracks.length > 0) enqueue(radioTracks); .map(songToTrack)
.filter(t => t.id !== seedTrack.id)
.map(t => ({ ...t, radioAdded: true as const }));
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
} catch (e) { } catch (e) {
console.error('Failed to load radio queue', e); console.error('Failed to load radio queue', e);
} }
} else { } else {
// Artist radio: no seed track, fetch in parallel and play when ready // Artist radio: fire both calls immediately but don't wait for the slow one.
// getTopSongs is fast (local library) — start playback as soon as it resolves.
// getSimilarSongs2 is slow (Last.fm) — enrich the queue in the background.
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
try { try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); const top = await getTopSongs(artistName);
const radioTracks = [...top, ...similar].map(songToTrack); const topTracks = top.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
if (radioTracks.length > 0) playTrack(radioTracks[0], radioTracks); if (topTracks.length === 0) {
// No local top songs — fall back to waiting for similar tracks
const similar = await similarPromise;
const fallback = similar.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
if (fallback.length === 0) return;
const state = usePlayerStore.getState();
if (state.currentTrack) {
state.enqueueRadio(fallback, artistId);
} else {
state.setRadioArtistId(artistId);
playTrack(fallback[0], fallback);
}
return;
}
// Start playback immediately from top songs
const state = usePlayerStore.getState();
if (state.currentTrack) {
state.enqueueRadio(topTracks, artistId);
} else {
state.setRadioArtistId(artistId);
playTrack(topTracks[0], topTracks);
}
// Enrich with similar tracks in the background
similarPromise.then(similar => {
const similarTracks = similar
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
.filter(t => !topTracks.some(top => top.id === t.id));
if (similarTracks.length === 0) return;
// Collect pending (upcoming) radio tracks so enqueueRadio re-inserts them
// together with the new similar tracks rather than losing them.
const { queue, queueIndex } = usePlayerStore.getState();
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
});
} catch (e) { } catch (e) {
console.error('Failed to start radio', e); console.error('Failed to start radio', e);
} }
+12 -1
View File
@@ -30,6 +30,8 @@ export default function LyricsPane({ currentTrack }: Props) {
const hasSynced = syncedLines !== null && syncedLines.length > 0; const hasSynced = syncedLines !== null && syncedLines.length > 0;
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0); const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]); const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const prevActive = useRef(-1); const prevActive = useRef(-1);
@@ -103,6 +105,13 @@ export default function LyricsPane({ currentTrack }: Props) {
); );
} }
const getLyricLineClass = (i: number, active: number) => {
const base = 'lyrics-line';
if (i > active) return base;
if (i < active) return `${base} completed`;
return `${base} active`;
};
return ( return (
<div className="lyrics-pane"> <div className="lyrics-pane">
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>} {loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
@@ -113,7 +122,9 @@ export default function LyricsPane({ currentTrack }: Props) {
<div <div
key={i} key={i}
ref={el => { lineRefs.current[i] = el; }} ref={el => { lineRefs.current[i] = el; }}
className={`lyrics-line${i === activeIdx ? ' active' : ''}`} className={getLyricLineClass(i, activeIdx)}
onClick={() => { if (duration > 0) seek(line.time / duration); }}
style={{ cursor: 'pointer' }}
> >
{line.text || '\u00A0'} {line.text || '\u00A0'}
</div> </div>
+7 -1
View File
@@ -64,6 +64,12 @@ export default function PlayerBar() {
setVolume(parseFloat(e.target.value)); setVolume(parseFloat(e.target.value));
}, [setVolume]); }, [setVolume]);
const handleVolumeWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
setVolume(Math.max(0, Math.min(1, volume + delta)));
}, [volume, setVolume]);
const volumeStyle = { const volumeStyle = {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`, background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
}; };
@@ -203,7 +209,7 @@ export default function PlayerBar() {
> >
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />} {volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button> </button>
<div className="player-volume-slider-wrap"> <div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
{showVolPct && ( {showVolPct && (
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}> <span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
{Math.round(volume * 100)}% {Math.round(volume * 100)}%
+8 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useMemo } from 'react'; import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, Radio } from 'lucide-react'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useCachedUrl } from './CachedImage'; import { useCachedUrl } from './CachedImage';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -496,7 +496,7 @@ export default function QueuePanel() {
data-tooltip={t('queue.infiniteQueue')} data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')} aria-label={t('queue.infiniteQueue')}
> >
<Radio size={13} /> <ArrowUpToLine size={13} />
</button> </button>
</div> </div>
@@ -511,6 +511,7 @@ export default function QueuePanel() {
queue.map((track, idx) => { queue.map((track, idx) => {
const isPlaying = idx === queueIndex; const isPlaying = idx === queueIndex;
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {}; let dragStyle: React.CSSProperties = {};
if (isPsyDragging && psyDragFromIdxRef.current === idx) { if (isPsyDragging && psyDragFromIdxRef.current === idx) {
@@ -525,6 +526,11 @@ export default function QueuePanel() {
return ( return (
<React.Fragment key={`${track.id}-${idx}`}> <React.Fragment key={`${track.id}-${idx}`}>
{isFirstRadioAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
</div>
)}
{isFirstAutoAdded && ( {isFirstAutoAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}> <div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span> <span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
+10
View File
@@ -394,6 +394,7 @@ const enTranslation = {
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio', aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic', aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
aboutContributorsLabel: 'Contributors', aboutContributorsLabel: 'Contributors',
aboutSpecialThanksLabel: 'Special Thanks',
changelog: 'Changelog', changelog: 'Changelog',
showChangelogOnUpdate: "Show 'What's New' on update", showChangelogOnUpdate: "Show 'What's New' on update",
showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.", showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.",
@@ -551,6 +552,7 @@ const enTranslation = {
crossfade: 'Crossfade', crossfade: 'Crossfade',
infiniteQueue: 'Infinite Queue', infiniteQueue: 'Infinite Queue',
autoAdded: '— Added automatically —', autoAdded: '— Added automatically —',
radioAdded: '— Radio —',
hide: 'Hide', hide: 'Hide',
close: 'Close', close: 'Close',
nextTracks: 'Next Tracks', nextTracks: 'Next Tracks',
@@ -1066,6 +1068,7 @@ const deTranslation = {
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio', aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic', aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
aboutContributorsLabel: 'Mitwirkende', aboutContributorsLabel: 'Mitwirkende',
aboutSpecialThanksLabel: 'Besonderer Dank',
changelog: 'Changelog', changelog: 'Changelog',
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen", showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.', showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.',
@@ -1223,6 +1226,7 @@ const deTranslation = {
crossfade: 'Crossfade', crossfade: 'Crossfade',
infiniteQueue: 'Endlose Warteschlange', infiniteQueue: 'Endlose Warteschlange',
autoAdded: '— Automatisch hinzugefügt —', autoAdded: '— Automatisch hinzugefügt —',
radioAdded: '— Radio —',
hide: 'Verbergen', hide: 'Verbergen',
close: 'Schließen', close: 'Schließen',
nextTracks: 'Nächste Titel', nextTracks: 'Nächste Titel',
@@ -1738,6 +1742,7 @@ const frTranslation = {
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio', aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic', aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
aboutContributorsLabel: 'Contributeurs', aboutContributorsLabel: 'Contributeurs',
aboutSpecialThanksLabel: 'Remerciements spéciaux',
changelog: 'Journal des modifications', changelog: 'Journal des modifications',
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour", showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.', showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.',
@@ -1895,6 +1900,7 @@ const frTranslation = {
crossfade: 'Fondu', crossfade: 'Fondu',
infiniteQueue: 'File infinie', infiniteQueue: 'File infinie',
autoAdded: '— Ajouté automatiquement —', autoAdded: '— Ajouté automatiquement —',
radioAdded: '— Radio —',
hide: 'Masquer', hide: 'Masquer',
close: 'Fermer', close: 'Fermer',
nextTracks: 'Pistes suivantes', nextTracks: 'Pistes suivantes',
@@ -2410,6 +2416,7 @@ const nlTranslation = {
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio', aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic', aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
aboutContributorsLabel: 'Bijdragers', aboutContributorsLabel: 'Bijdragers',
aboutSpecialThanksLabel: 'Speciale dank',
changelog: 'Wijzigingslog', changelog: 'Wijzigingslog',
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update", showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.', showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.',
@@ -2567,6 +2574,7 @@ const nlTranslation = {
crossfade: 'Overgang', crossfade: 'Overgang',
infiniteQueue: 'Oneindige wachtrij', infiniteQueue: 'Oneindige wachtrij',
autoAdded: '— Automatisch toegevoegd —', autoAdded: '— Automatisch toegevoegd —',
radioAdded: '— Radio —',
hide: 'Verbergen', hide: 'Verbergen',
close: 'Sluiten', close: 'Sluiten',
nextTracks: 'Volgende nummers', nextTracks: 'Volgende nummers',
@@ -3082,6 +3090,7 @@ const zhTranslation = {
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建', aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发', aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
aboutContributorsLabel: '贡献者', aboutContributorsLabel: '贡献者',
aboutSpecialThanksLabel: '特别感谢',
changelog: '更新日志', changelog: '更新日志',
showChangelogOnUpdate: '更新时显示"新功能"', showChangelogOnUpdate: '更新时显示"新功能"',
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。', showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
@@ -3239,6 +3248,7 @@ const zhTranslation = {
crossfade: '交叉淡入淡出', crossfade: '交叉淡入淡出',
infiniteQueue: '无限队列', infiniteQueue: '无限队列',
autoAdded: '— 自动添加 —', autoAdded: '— 自动添加 —',
radioAdded: '— 收音机 —',
hide: '隐藏', hide: '隐藏',
close: '关闭', close: '关闭',
nextTracks: '即将播放', nextTracks: '即将播放',
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Music, Star, ExternalLink, MicVocal } from 'lucide-react'; import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore'; import { useLyricsStore } from '../store/lyricsStore';
import { import {
@@ -296,7 +296,7 @@ export default function NowPlaying() {
<button onClick={toggleStar} className="np-star-btn" <button onClick={toggleStar} className="np-star-btn"
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
> >
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} /> <Heart size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
</button> </button>
<button <button
className="np-star-btn" className="np-star-btn"
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle } from 'lucide-react'; import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart } from 'lucide-react';
import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import { import {
getPlaylist, updatePlaylist, search, setRating, star, unstar, getPlaylist, updatePlaylist, search, setRating, star, unstar,
@@ -601,7 +601,7 @@ export default function PlaylistDetail() {
onClick={e => handleToggleStar(song, e)} onClick={e => handleToggleStar(song, e)}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }} style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
> >
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} /> <Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button> </button>
</div> </div>
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; import { Play, Star, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
@@ -519,7 +519,7 @@ export default function RandomMix() {
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')} data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }} style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
> >
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} /> <Heart size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
</button> </button>
</div> </div>
+47 -17
View File
@@ -55,6 +55,31 @@ const CONTRIBUTORS = [
'songToTrack() — unified track construction across all sources', 'songToTrack() — unified track construction across all sources',
], ],
}, },
{
github: 'nisarg-78',
since: '1.29.0',
contributions: [
'Click-to-seek in synced lyrics (PR #38)',
'Volume scroll wheel on volume slider (PR #38)',
'Lyrics line visual states: active / completed / upcoming (PR #38)',
],
},
{
github: 'JulianNymark',
since: '1.29.0',
contributions: [
'OGG/Vorbis container support via symphonia-format-ogg (PR #42)',
'Themed toast notifications for audio playback errors (PR #43)',
'Human-readable audio error messages (PR #44)',
],
},
] as const;
const SPECIAL_THANKS = [
{
github: 'netherguy4',
reason: 'Countless constructive feature ideas and thoughtful feedback',
},
] as const; ] as const;
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about'; type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
@@ -415,24 +440,7 @@ export default function Settings() {
</label> </label>
</div> </div>
<div className="divider" />
{/* Infinite Queue */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.infiniteQueue')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.infiniteQueueDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.infiniteQueue')}>
<input type="checkbox" checked={auth.infiniteQueueEnabled}
onChange={e => auth.setInfiniteQueueEnabled(e.target.checked)} id="infinite-queue-toggle" />
<span className="toggle-track" />
</label>
</div>
</div> </div>
</section> </section>
@@ -1129,6 +1137,28 @@ export default function Settings() {
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56, flexShrink: 0, paddingTop: 2, fontSize: 13 }}>{t('settings.aboutSpecialThanksLabel')}</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', flex: 1 }}>
{SPECIAL_THANKS.map(s => (
<div key={s.github} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<img
src={`https://github.com/${s.github}.png?size=32`}
width={22} height={22}
style={{ borderRadius: '50%', flexShrink: 0 }}
alt={s.github}
/>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)', fontWeight: 600, fontSize: 13 }}
onClick={() => openUrl(`https://github.com/${s.github}`)}
>
@{s.github}
</button>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}> {s.reason}</span>
</div>
))}
</div>
</div>
</div> </div>
<button <button
+135 -4
View File
@@ -2,7 +2,8 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs } from '../api/subsonic'; import { showToast } from '../utils/toast';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore'; import { useOfflineStore } from './offlineStore';
@@ -29,6 +30,7 @@ export interface Track {
samplingRate?: number; samplingRate?: number;
bitDepth?: number; bitDepth?: number;
autoAdded?: boolean; autoAdded?: boolean;
radioAdded?: boolean;
} }
export function songToTrack(song: SubsonicSong): Track { export function songToTrack(song: SubsonicSong): Track {
@@ -84,6 +86,8 @@ interface PlayerState {
setProgress: (t: number, duration: number) => void; setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void; enqueue: (tracks: Track[]) => void;
enqueueAt: (tracks: Track[], insertIndex: number) => void; enqueueAt: (tracks: Track[], insertIndex: number) => void;
enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void;
clearQueue: () => void; clearQueue: () => void;
isQueueVisible: boolean; isQueueVisible: boolean;
@@ -134,6 +138,14 @@ let isAudioPaused = false;
// playGeneration has moved on, preventing stale errors from skipping wrong tracks. // playGeneration has moved on, preventing stale errors from skipping wrong tracks.
let playGeneration = 0; let playGeneration = 0;
// Guard against concurrent infinite-queue fetches.
let infiniteQueueFetching = false;
// Guard against concurrent radio top-up fetches.
let radioFetching = false;
// Artist ID used to start the current radio session — persists across track
// advances so proactive loading works even when songs lack artistId.
let currentRadioArtistId: string | null = null;
// Debounce timer for seek slider drags. // Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null; let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Target time of the last seek — blocks stale Rust progress ticks until the // Target time of the last seek — blocks stale Rust progress ticks until the
@@ -321,12 +333,16 @@ function handleAudioTrackSwitched(duration: number) {
function handleAudioError(message: string) { function handleAudioError(message: string) {
console.error('[psysonic] Audio error from backend:', message); console.error('[psysonic] Audio error from backend:', message);
isAudioPaused = false; isAudioPaused = false;
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
const gen = playGeneration; const gen = playGeneration;
usePlayerStore.setState({ isPlaying: false }); usePlayerStore.setState({ isPlaying: false });
setTimeout(() => { setTimeout(() => {
if (playGeneration !== gen) return; if (playGeneration !== gen) return;
usePlayerStore.getState().next(); usePlayerStore.getState().next();
}, 500); }, 1500);
} }
/** /**
@@ -676,12 +692,91 @@ export const usePlayerStore = create<PlayerState>()(
const nextIdx = queueIndex + 1; const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) { if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue); get().playTrack(queue[nextIdx], queue);
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
// so the queue never runs dry without a visible loading pause.
const { infiniteQueueEnabled } = useAuthStore.getState();
if (infiniteQueueEnabled && repeatMode === 'off' && !infiniteQueueFetching) {
const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length;
if (remainingAuto <= 2) {
infiniteQueueFetching = true;
getRandomSongs(5, currentTrack?.genre).then(songs => {
if (songs.length > 0) {
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
set(state => ({ queue: [...state.queue, ...newTracks] }));
}
}).catch(() => {}).finally(() => { infiniteQueueFetching = false; });
}
}
// Proactively top up radio tracks when ≤ 2 remain — always, regardless
// of infinite queue setting.
const nextTrack = queue[nextIdx];
if (nextTrack.radioAdded && !radioFetching) {
const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length;
if (remainingRadio <= 2) {
const artistId = nextTrack.artistId ?? currentRadioArtistId ?? null;
const artistName = nextTrack.artist;
if (artistId) {
radioFetching = true;
Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)])
.then(([similar, top]) => {
const existingIds = new Set(get().queue.map(t => t.id));
const fresh: Track[] = [...top, ...similar]
.map(songToTrack)
.filter(t => !existingIds.has(t.id))
.slice(0, 10)
.map(t => ({ ...t, radioAdded: true as const }));
if (fresh.length > 0) {
set(state => ({ queue: [...state.queue, ...fresh] }));
}
})
.catch(() => {})
.finally(() => { radioFetching = false; });
}
}
}
} else if (repeatMode === 'all' && queue.length > 0) { } else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue); get().playTrack(queue[0], queue);
} else { } else {
// Queue exhausted. Check radio first (independent of infinite queue setting),
// then infinite queue, then stop.
if (currentTrack?.radioAdded && !radioFetching) {
const artistId = currentTrack.artistId ?? currentRadioArtistId ?? null;
if (artistId) {
radioFetching = true;
Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)])
.then(([similar, top]) => {
radioFetching = false;
const existingIds = new Set(get().queue.map(t => t.id));
const fresh: Track[] = [...top, ...similar]
.map(songToTrack)
.filter(t => !existingIds.has(t.id))
.slice(0, 10)
.map(t => ({ ...t, radioAdded: true as const }));
if (fresh.length > 0) {
const currentQueue = get().queue;
const newQueue = [...currentQueue, ...fresh];
get().playTrack(fresh[0], newQueue);
} else {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
}
})
.catch(() => {
radioFetching = false;
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
});
return;
}
}
const { infiniteQueueEnabled } = useAuthStore.getState(); const { infiniteQueueEnabled } = useAuthStore.getState();
if (infiniteQueueEnabled && repeatMode === 'off') { if (infiniteQueueEnabled && repeatMode === 'off') {
getRandomSongs(25, currentTrack?.genre).then(songs => { if (infiniteQueueFetching) return;
infiniteQueueFetching = true;
getRandomSongs(5, currentTrack?.genre).then(songs => {
infiniteQueueFetching = false;
if (songs.length === 0) { if (songs.length === 0) {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; isAudioPaused = false;
@@ -693,6 +788,7 @@ export const usePlayerStore = create<PlayerState>()(
const newQueue = [...currentQueue, ...newTracks]; const newQueue = [...currentQueue, ...newTracks];
get().playTrack(newTracks[0], newQueue); get().playTrack(newTracks[0], newQueue);
}).catch(() => { }).catch(() => {
infiniteQueueFetching = false;
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; isAudioPaused = false;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
@@ -748,7 +844,42 @@ export const usePlayerStore = create<PlayerState>()(
// ── queue management ───────────────────────────────────────────────────── // ── queue management ─────────────────────────────────────────────────────
enqueue: (tracks) => { enqueue: (tracks) => {
set(state => { set(state => {
const newQueue = [...state.queue, ...tracks]; // Insert before the first upcoming auto-added track so the
// "Added automatically" separator always stays at the boundary.
const firstAutoIdx = state.queue.findIndex(
(t, i) => t.autoAdded && i > state.queueIndex
);
const newQueue = firstAutoIdx === -1
? [...state.queue, ...tracks]
: [
...state.queue.slice(0, firstAutoIdx),
...tracks,
...state.queue.slice(firstAutoIdx),
];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
});
},
setRadioArtistId: (artistId) => { currentRadioArtistId = artistId; },
enqueueRadio: (tracks, artistId) => {
if (artistId) currentRadioArtistId = artistId;
set(state => {
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
// again replaces the pending radio batch instead of stacking on top.
const beforeAndCurrent = state.queue.slice(0, state.queueIndex + 1);
const upcoming = state.queue.slice(state.queueIndex + 1).filter(t => !t.radioAdded);
// Insert new radio tracks before any autoAdded tracks in the upcoming section.
const firstAutoIdx = upcoming.findIndex(t => t.autoAdded);
const merged = firstAutoIdx === -1
? [...upcoming, ...tracks]
: [
...upcoming.slice(0, firstAutoIdx),
...tracks,
...upcoming.slice(firstAutoIdx),
];
const newQueue = [...beforeAndCurrent, ...merged];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime); syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue }; return { queue: newQueue };
}); });
+9 -1
View File
@@ -1953,7 +1953,7 @@
.lyrics-line { .lyrics-line {
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
color: var(--text-muted); color: var(--text-secondary);
text-align: center; text-align: center;
padding: 5px 8px; padding: 5px 8px;
border-radius: 6px; border-radius: 6px;
@@ -1962,10 +1962,18 @@
cursor: default; cursor: default;
} }
.lyrics-line.completed {
color: var(--text-muted);
}
.lyrics-line.active { .lyrics-line.active {
color: var(--accent); color: var(--accent);
} }
.lyrics-line:hover:not(.active) {
color: var(--text-secondary);
}
.lyrics-plain { .lyrics-plain {
padding: 4px 0 16px; padding: 4px 0 16px;
} }
+1 -1
View File
@@ -693,7 +693,7 @@
/* Star + Last.fm heart — visually separated from track title */ /* Star + Last.fm heart — visually separated from track title */
.player-star-btn { .player-star-btn {
margin-left: var(--space-3); margin: var(--space-1);
} }
.player-btn-primary { .player-btn-primary {
+5805 -3834
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
/**
* Lightweight DOM-based toast notification.
* Uses the app's CSS custom properties so it respects the active theme.
* Multiple toasts stack vertically above each other.
*/
const TOAST_GAP = 8;
const TOAST_BOTTOM_ANCHOR = 100;
function getActiveToasts(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('.psysonic-toast'));
}
function reflow(): void {
const toasts = getActiveToasts();
let bottom = TOAST_BOTTOM_ANCHOR;
for (let i = toasts.length - 1; i >= 0; i--) {
toasts[i].style.bottom = `${bottom}px`;
bottom += toasts[i].offsetHeight + TOAST_GAP;
}
}
export type ToastVariant = 'error' | 'info';
export function showToast(text: string, durationMs = 4000, variant: ToastVariant = 'info'): void {
const isError = variant === 'error';
const toast = document.createElement('div');
toast.className = 'psysonic-toast';
const icon = document.createElement('span');
icon.textContent = isError ? '✕' : '';
icon.style.cssText = `
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: ${isError ? 'var(--danger)' : 'var(--accent)'};
color: var(--bg-app);
line-height: 1;
`;
const msg = document.createElement('span');
msg.textContent = text;
msg.style.cssText = `flex: 1; min-width: 0;`;
toast.style.cssText = `
position: fixed;
bottom: ${TOAST_BOTTOM_ANCHOR}px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid ${isError ? 'var(--danger)' : 'var(--border)'};
border-left: 3px solid ${isError ? 'var(--danger)' : 'var(--accent)'};
padding: 10px 16px;
border-radius: 8px;
font-size: 13.5px;
font-weight: 500;
z-index: 999999;
pointer-events: none;
box-shadow: 0 4px 24px rgba(0,0,0,0.45)${isError ? ', 0 0 0 1px color-mix(in srgb, var(--danger) 20%, transparent)' : ''};
white-space: normal;
word-break: break-word;
transition: bottom 150ms ease;
max-width: 480px;
width: max-content;
`;
toast.appendChild(icon);
toast.appendChild(msg);
document.body.appendChild(toast);
reflow();
setTimeout(() => {
toast.remove();
reflow();
}, durationMs);
}