feat: v1.13.0 — SVG Logo, Marquee, Player UX, Global Shortcuts fix

### Added
- SVG logo with theme-adaptive gradient in sidebar (full wordmark + P-icon for collapsed state)
- Player bar: song title/artist marquee scroll on overflow
- Player bar: live volume percentage tooltip on slider hover

### Changed
- Sidebar collapse button moved to right-edge hover tab
- Player bar: fixed 320px track info width, increased waveform margins
- Settings: Server tab opens by default
- Crossfade: experimental badge removed (stable)
- Help page: Lyrics/Keybindings/Font entries added, theme count corrected

### Fixed
- Global shortcuts double-fire (Rust-side ShortcutMap idempotency fix)
- W98 theme: comprehensive contrast fixes for navy hover backgrounds
- Help page: removed orphaned translation key under Playback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-22 20:24:14 +01:00
parent d927ef2082
commit 5516d95b52
20 changed files with 781 additions and 97 deletions
+49
View File
@@ -0,0 +1,49 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
interface Props {
text: string;
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
}
export default function MarqueeText({ text, className, style, onClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
text.style.display = 'inline-block';
const textWidth = text.getBoundingClientRect().width;
text.style.display = '';
const overflow = textWidth - container.clientWidth;
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [text, measure]);
return (
<div
ref={containerRef}
className={`marquee-wrap${className ? ` ${className}` : ''}`}
style={style}
onClick={onClick}
>
<span
ref={textRef}
className={scrollAmount > 0 ? 'marquee-scroll' : ''}
style={scrollAmount > 0 ? { '--marquee-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
>
{text}
</span>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
interface Props {
className?: string;
style?: React.CSSProperties;
}
export default function PSmallLogo({ className, style }: Props) {
return (
<svg viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
<linearGradient id="psmallGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--accent)" />
<stop offset="100%" stopColor="var(--ctp-blue)" />
</linearGradient>
</defs><g
id="layer1"
transform="translate(220.53237,27.789086)"><path
fill="url(#psmallGrad)"
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
id="path1" /></g></svg>
);
}
+29 -20
View File
@@ -13,6 +13,7 @@ import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -25,6 +26,7 @@ export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const [showVolPct, setShowVolPct] = useState(false);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
@@ -77,20 +79,18 @@ export default function PlayerBar() {
)}
</div>
<div className="player-track-meta">
<div
<MarqueeText
text={currentTrack?.title ?? t('player.noTitle')}
className="player-track-name"
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
>
{currentTrack?.title ?? t('player.noTitle')}
</div>
<div
/>
<MarqueeText
text={currentTrack?.artist ?? '—'}
className="player-track-artist"
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>
{currentTrack?.artist ?? '—'}
</div>
/>
</div>
{currentTrack && lastfmSessionKey && (
<button
@@ -174,18 +174,27 @@ export default function PlayerBar() {
>
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
id="player-volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
/>
<div className="player-volume-slider-wrap">
{showVolPct && (
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
{Math.round(volume * 100)}%
</span>
)}
<input
type="range"
id="player-volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
onMouseEnter={() => setShowVolPct(true)}
onMouseLeave={() => setShowVolPct(false)}
/>
</div>
</div>
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
File diff suppressed because one or more lines are too long
+15 -15
View File
@@ -8,10 +8,8 @@ import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines
} from 'lucide-react';
const PsysonicLogo = () => (
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" />
);
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
const navItems = [
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
@@ -99,19 +97,21 @@ export default function Sidebar({
return (
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand">
<button
className="collapse-btn"
onClick={toggleCollapse}
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
data-tooltip-pos="bottom"
style={{ padding: 0 }}
>
{isCollapsed ? <PanelLeft size={24} /> : <PanelLeftClose size={24} />}
</button>
{!isCollapsed && <PsysonicLogo />}
{!isCollapsed && <span className="brand-name">Psysonic</span>}
{isCollapsed
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
}
</div>
<button
className="collapse-btn"
onClick={toggleCollapse}
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
data-tooltip-pos="right"
>
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
</button>
<nav className="sidebar-nav" aria-label="Hauptnavigation">
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
{navItems.map(item => (