mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
Generated
+13
@@ -4524,6 +4524,7 @@ dependencies = [
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
@@ -4620,6 +4621,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
|
||||
@@ -31,7 +31,7 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
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"] }
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
|
||||
+28
-9
@@ -229,9 +229,15 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Restart the fade envelope after seeking (avoids a mid-song click if
|
||||
// the user seeks to the very beginning while a fade was in progress).
|
||||
self.sample_count = 0;
|
||||
// For mid-track seeks: skip straight to unity gain so the new position
|
||||
// plays at full volume immediately — no audible fade-in glitch.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -478,13 +484,20 @@ impl SizedDecoder {
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.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
|
||||
.tracks()
|
||||
.iter()
|
||||
.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 total_duration = track.codec_params.time_base
|
||||
@@ -493,7 +506,13 @@ impl SizedDecoder {
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.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;
|
||||
|
||||
@@ -505,7 +524,7 @@ impl SizedDecoder {
|
||||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||||
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; }
|
||||
match decoder.decode(&packet) {
|
||||
@@ -513,10 +532,10 @@ impl SizedDecoder {
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => {
|
||||
decode_errors += 1;
|
||||
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}")),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,50 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
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.
|
||||
/// `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.
|
||||
@@ -498,6 +542,7 @@ pub fn run() {
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
relaunch_after_update,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
+1
-14
@@ -1,4 +1,5 @@
|
||||
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 { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@@ -433,20 +434,6 @@ export default function App() {
|
||||
|
||||
const handleExport = async (since: number) => {
|
||||
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 {
|
||||
const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums');
|
||||
const result = await exportNewAlbumsImage(since);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
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 { RefreshCw, Download, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -81,7 +81,7 @@ export default function AppUpdater() {
|
||||
setState({ phase: 'installing' });
|
||||
}
|
||||
});
|
||||
await relaunch();
|
||||
await invoke('relaunch_after_update');
|
||||
} catch (e) {
|
||||
console.error('Update failed', e);
|
||||
setState({ phase: 'available', version: savedVersion, update });
|
||||
|
||||
@@ -217,20 +217,60 @@ export default function ContextMenu() {
|
||||
} else {
|
||||
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 {
|
||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||
const radioTracks = [...top, ...similar].map(songToTrack).filter(t => t.id !== seedTrack.id);
|
||||
if (radioTracks.length > 0) enqueue(radioTracks);
|
||||
const radioTracks = [...top, ...similar]
|
||||
.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) {
|
||||
console.error('Failed to load radio queue', e);
|
||||
}
|
||||
} 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 {
|
||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||
const radioTracks = [...top, ...similar].map(songToTrack);
|
||||
if (radioTracks.length > 0) playTrack(radioTracks[0], radioTracks);
|
||||
const top = await getTopSongs(artistName);
|
||||
const topTracks = top.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
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) {
|
||||
console.error('Failed to start radio', e);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
|
||||
const hasSynced = syncedLines !== null && syncedLines.length > 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 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 (
|
||||
<div className="lyrics-pane">
|
||||
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
||||
@@ -113,7 +122,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
<div
|
||||
key={i}
|
||||
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'}
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,12 @@ export default function PlayerBar() {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
}, [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 = {
|
||||
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} />}
|
||||
</button>
|
||||
<div className="player-volume-slider-wrap">
|
||||
<div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
|
||||
{showVolPct && (
|
||||
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
|
||||
{Math.round(volume * 100)}%
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
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 { useCachedUrl } from './CachedImage';
|
||||
import { useEffect } from 'react';
|
||||
@@ -496,7 +496,7 @@ export default function QueuePanel() {
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<Radio size={13} />
|
||||
<ArrowUpToLine size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -511,6 +511,7 @@ export default function QueuePanel() {
|
||||
queue.map((track, idx) => {
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
|
||||
@@ -525,6 +526,11 @@ export default function QueuePanel() {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
|
||||
|
||||
+10
@@ -394,6 +394,7 @@ const enTranslation = {
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
aboutContributorsLabel: 'Contributors',
|
||||
aboutSpecialThanksLabel: 'Special Thanks',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "Show 'What's New' on update",
|
||||
showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.",
|
||||
@@ -551,6 +552,7 @@ const enTranslation = {
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
autoAdded: '— Added automatically —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
@@ -1066,6 +1068,7 @@ const deTranslation = {
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||
aboutContributorsLabel: 'Mitwirkende',
|
||||
aboutSpecialThanksLabel: 'Besonderer Dank',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
|
||||
showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.',
|
||||
@@ -1223,6 +1226,7 @@ const deTranslation = {
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
autoAdded: '— Automatisch hinzugefügt —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
@@ -1738,6 +1742,7 @@ const frTranslation = {
|
||||
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
|
||||
aboutContributorsLabel: 'Contributeurs',
|
||||
aboutSpecialThanksLabel: 'Remerciements spéciaux',
|
||||
changelog: 'Journal des modifications',
|
||||
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
|
||||
showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.',
|
||||
@@ -1895,6 +1900,7 @@ const frTranslation = {
|
||||
crossfade: 'Fondu',
|
||||
infiniteQueue: 'File infinie',
|
||||
autoAdded: '— Ajouté automatiquement —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Masquer',
|
||||
close: 'Fermer',
|
||||
nextTracks: 'Pistes suivantes',
|
||||
@@ -2410,6 +2416,7 @@ const nlTranslation = {
|
||||
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
|
||||
aboutContributorsLabel: 'Bijdragers',
|
||||
aboutSpecialThanksLabel: 'Speciale dank',
|
||||
changelog: 'Wijzigingslog',
|
||||
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
|
||||
showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.',
|
||||
@@ -2567,6 +2574,7 @@ const nlTranslation = {
|
||||
crossfade: 'Overgang',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
autoAdded: '— Automatisch toegevoegd —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Sluiten',
|
||||
nextTracks: 'Volgende nummers',
|
||||
@@ -3082,6 +3090,7 @@ const zhTranslation = {
|
||||
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
|
||||
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
|
||||
aboutContributorsLabel: '贡献者',
|
||||
aboutSpecialThanksLabel: '特别感谢',
|
||||
changelog: '更新日志',
|
||||
showChangelogOnUpdate: '更新时显示"新功能"',
|
||||
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
|
||||
@@ -3239,6 +3248,7 @@ const zhTranslation = {
|
||||
crossfade: '交叉淡入淡出',
|
||||
infiniteQueue: '无限队列',
|
||||
autoAdded: '— 自动添加 —',
|
||||
radioAdded: '— 收音机 —',
|
||||
hide: '隐藏',
|
||||
close: '关闭',
|
||||
nextTracks: '即将播放',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { useLyricsStore } from '../store/lyricsStore';
|
||||
import {
|
||||
@@ -296,7 +296,7 @@ export default function NowPlaying() {
|
||||
<button onClick={toggleStar} className="np-star-btn"
|
||||
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
|
||||
className="np-star-btn"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
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 {
|
||||
getPlaylist, updatePlaylist, search, setRating, star, unstar,
|
||||
@@ -601,7 +601,7 @@ export default function PlaylistDetail() {
|
||||
onClick={e => handleToggleStar(song, e)}
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
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 { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
@@ -519,7 +519,7 @@ export default function RandomMix() {
|
||||
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)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
+47
-17
@@ -55,6 +55,31 @@ const CONTRIBUTORS = [
|
||||
'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;
|
||||
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
|
||||
@@ -415,24 +440,7 @@ export default function Settings() {
|
||||
</label>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -1129,6 +1137,28 @@ export default function Settings() {
|
||||
</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>
|
||||
|
||||
<button
|
||||
|
||||
+135
-4
@@ -2,7 +2,8 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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 { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
@@ -29,6 +30,7 @@ export interface Track {
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
autoAdded?: boolean;
|
||||
radioAdded?: boolean;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
@@ -84,6 +86,8 @@ interface PlayerState {
|
||||
setProgress: (t: number, duration: number) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
enqueueAt: (tracks: Track[], insertIndex: number) => void;
|
||||
enqueueRadio: (tracks: Track[], artistId?: string) => void;
|
||||
setRadioArtistId: (artistId: string) => void;
|
||||
clearQueue: () => void;
|
||||
|
||||
isQueueVisible: boolean;
|
||||
@@ -134,6 +138,14 @@ let isAudioPaused = false;
|
||||
// playGeneration has moved on, preventing stale errors from skipping wrong tracks.
|
||||
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.
|
||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
// 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) {
|
||||
console.error('[psysonic] Audio error from backend:', message);
|
||||
isAudioPaused = false;
|
||||
|
||||
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
|
||||
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
|
||||
|
||||
const gen = playGeneration;
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
usePlayerStore.getState().next();
|
||||
}, 500);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -676,12 +692,91 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
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) {
|
||||
get().playTrack(queue[0], queue);
|
||||
} 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();
|
||||
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) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
@@ -693,6 +788,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const newQueue = [...currentQueue, ...newTracks];
|
||||
get().playTrack(newTracks[0], newQueue);
|
||||
}).catch(() => {
|
||||
infiniteQueueFetching = false;
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
@@ -748,7 +844,42 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
// ── queue management ─────────────────────────────────────────────────────
|
||||
enqueue: (tracks) => {
|
||||
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);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
|
||||
@@ -1953,7 +1953,7 @@
|
||||
.lyrics-line {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
@@ -1962,10 +1962,18 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lyrics-line.completed {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lyrics-line.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lyrics-line:hover:not(.active) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.lyrics-plain {
|
||||
padding: 4px 0 16px;
|
||||
}
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
|
||||
/* Star + Last.fm heart — visually separated from track title */
|
||||
.player-star-btn {
|
||||
margin-left: var(--space-3);
|
||||
margin: var(--space-1);
|
||||
}
|
||||
|
||||
.player-btn-primary {
|
||||
|
||||
+5805
-3834
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user