From 7263d93d42d4c031f36b9bbc41d3383c52aa0b2e Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 2 Apr 2026 22:17:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v1.29.0=20=E2=80=94=20Radio=20fast-star?= =?UTF-8?q?t,=20seek=20fix,=20OGG,=20error=20toasts,=20contributors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/Cargo.lock | 13 + src-tauri/Cargo.toml | 2 +- src-tauri/src/audio.rs | 37 +- src-tauri/src/lib.rs | 45 + src/App.tsx | 15 +- src/components/AppUpdater.tsx | 4 +- src/components/ContextMenu.tsx | 54 +- src/components/LyricsPane.tsx | 13 +- src/components/PlayerBar.tsx | 8 +- src/components/QueuePanel.tsx | 10 +- src/i18n.ts | 10 + src/pages/NowPlaying.tsx | 4 +- src/pages/PlaylistDetail.tsx | 4 +- src/pages/RandomMix.tsx | 4 +- src/pages/Settings.tsx | 64 +- src/store/playerStore.ts | 139 +- src/styles/components.css | 10 +- src/styles/layout.css | 2 +- src/styles/theme.css | 9639 +++++++++++++++++++------------- src/utils/toast.ts | 87 + 20 files changed, 6264 insertions(+), 3900 deletions(-) create mode 100644 src/utils/toast.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6e68a05d..d067a0ea 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15f81857..c5c33a80 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 62f5b7e1..fa7a2769 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -229,9 +229,15 @@ impl> Source for EqualPowerFadeIn { fn sample_rate(&self) -> u32 { self.inner.sample_rate() } fn total_duration(&self) -> Option { 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}")), } }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 818e5de7..8ebe36c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/App.tsx b/src/App.tsx index 3e36928f..2eb59d9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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); diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx index 3b53b764..d107e9a7 100644 --- a/src/components/AppUpdater.tsx +++ b/src/components/AppUpdater.tsx @@ -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 }); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 9223969a..53bc724e 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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>); 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); } diff --git a/src/components/LyricsPane.tsx b/src/components/LyricsPane.tsx index fa0e8cf3..fffd5981 100644 --- a/src/components/LyricsPane.tsx +++ b/src/components/LyricsPane.tsx @@ -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 (
{loading &&

{t('player.lyricsLoading')}

} @@ -113,7 +122,9 @@ export default function LyricsPane({ currentTrack }: Props) {
{ 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'}
diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 8775a866..60e6011a 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -64,6 +64,12 @@ export default function PlayerBar() { setVolume(parseFloat(e.target.value)); }, [setVolume]); + const handleVolumeWheel = useCallback((e: React.WheelEvent) => { + 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 ? : } -
+
{showVolPct && ( {Math.round(volume * 100)}% diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 46802800..bff21bd1 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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')} > - +
@@ -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 ( + {isFirstRadioAdded && ( +
+ {t('queue.radioAdded')} +
+ )} {isFirstAutoAdded && (
{t('queue.autoAdded')} diff --git a/src/i18n.ts b/src/i18n.ts index 04830c68..72443673 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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: '即将播放', diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index dc8e3e05..6d2defbe 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -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() {
diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 8df45d19..565db8bd 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -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)' }} > - +
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6ec17411..4fc17d24 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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() {
-
- {/* Infinite Queue */} -
-
-
- {t('settings.infiniteQueue')} -
-
- {t('settings.infiniteQueueDesc')} -
-
- -
@@ -1129,6 +1137,28 @@ export default function Settings() { )} +
+ {t('settings.aboutSpecialThanksLabel')} +
+ {SPECIAL_THANKS.map(s => ( +
+ {s.github} + + — {s.reason} +
+ ))} +
+