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-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"
|
||||||
|
|||||||
@@ -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
@@ -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}")),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}%
|
||||||
|
|||||||
@@ -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
@@ -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: '即将播放',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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