From 5516d95b52440a9032ea923ad1d570052f1f9293 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sun, 22 Mar 2026 20:24:14 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v1.13.0=20=E2=80=94=20SVG=20Logo,=20Mar?= =?UTF-8?q?quee,=20Player=20UX,=20Global=20Shortcuts=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 --- CHANGELOG.md | 28 +++++++ CLAUDE.md | 2 +- package.json | 2 +- packages/aur/PKGBUILD | 2 +- public/p-small.svg | 41 ++++++++++ public/psysonic-inapp-logo.svg | 41 ++++++++++ src-tauri/src/lib.rs | 72 +++++++++++++++++- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 56 +++++++++----- src/components/MarqueeText.tsx | 49 ++++++++++++ src/components/PSmallLogo.tsx | 22 ++++++ src/components/PlayerBar.tsx | 49 +++++++----- src/components/PsysonicLogo.tsx | 22 ++++++ src/components/Sidebar.tsx | 30 ++++---- src/i18n.ts | 48 ++++++++++-- src/pages/Help.tsx | 4 +- src/pages/Settings.tsx | 98 ++++++++++++++++++++++-- src/store/globalShortcutsStore.ts | 103 +++++++++++++++++++++++++ src/styles/layout.css | 86 ++++++++++++++++----- src/styles/theme.css | 121 +++++++++++++++++++++++++++++- 20 files changed, 781 insertions(+), 97 deletions(-) create mode 100644 public/p-small.svg create mode 100644 public/psysonic-inapp-logo.svg create mode 100644 src/components/MarqueeText.tsx create mode 100644 src/components/PSmallLogo.tsx create mode 100644 src/components/PsysonicLogo.tsx create mode 100644 src/store/globalShortcutsStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed48ded5..adcaa9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.0] - 2026-03-22 + +### Added + +- **SVG Logo**: The Psysonic wordmark is now an inline SVG with a theme-adaptive gradient (`--accent` → `--ctp-blue`), matching the app's visual identity across all 47 themes. The collapsed sidebar shows a standalone P-icon with the same gradient. +- **Player Bar — Marquee**: Song title and artist name scroll smoothly when the text overflows the fixed-width track info area, pause briefly, then jump back and repeat. +- **Player Bar — Volume Tooltip**: A floating percentage label appears above the volume slider on hover and updates live while dragging. + +### Changed + +- **Sidebar — Collapse button**: Moved from the brand header to a small circular hover-tab on the right edge of the sidebar. Hidden until you hover over the sidebar, keeping the logo area uncluttered. +- **Player Bar — Layout**: Track info area is now a fixed 320 px width. Waveform section has increased margins on both sides for better visual separation between controls, waveform, and volume. +- **Settings**: Server tab is now the default when opening Settings. +- **Crossfade**: Experimental badge removed — considered stable. +- **Help page**: Added entries for Lyrics, Configurable Keybindings, and Font Picker. Theme count corrected to 47 themes across 7 groups. + +### Fixed + +- **Global shortcuts — double-fire**: Pressing a global shortcut (e.g. `Ctrl+Alt+→`) was triggering the action twice. Root cause: `on_shortcut()` in `tauri_plugin_global_shortcut` accumulates handlers per shortcut across JS HMR reloads. Fixed with a Rust-side `ShortcutMap` state that makes `register_global_shortcut` idempotent. +- **W98 theme**: Comprehensive contrast fixes across all interactive elements — hover states, buttons, queue items, settings panels, and toggles now use silver-grey (`#e0e0e0`) text on navy (`#000080`) backgrounds. +- **Help page**: Removed orphaned translation key that was rendering as raw text under the Playback section. + +### Beta + +- **Global Shortcuts** (Settings → Global Shortcuts): System-wide keyboard shortcuts that trigger playback actions while Psysonic is in the background. Functional on all platforms, but edge cases with certain key combinations or OS-level conflicts may still occur. + +--- + ## [1.12.0] - 2026-03-22 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 73ac1ade..541d26bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,4 +244,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele - **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`). - **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact. - **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors. -- **Version**: 1.12.0 +- **Version**: 1.13.0 diff --git a/package.json b/package.json index 2e3713ef..9fe680c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.12.0", + "version": "1.13.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index 8ce03b3b..9e3c9c82 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.12.0 +pkgver=1.13.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/public/p-small.svg b/public/p-small.svg new file mode 100644 index 00000000..fc900429 --- /dev/null +++ b/public/p-small.svg @@ -0,0 +1,41 @@ + + + + diff --git a/public/psysonic-inapp-logo.svg b/public/psysonic-inapp-logo.svg new file mode 100644 index 00000000..0a7be333 --- /dev/null +++ b/public/psysonic-inapp-logo.svg @@ -0,0 +1,41 @@ + + + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 00a3f828..e2df5da4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,12 +3,19 @@ mod audio; +use std::collections::HashMap; +use std::sync::Mutex; + use tauri::{ menu::{MenuBuilder, MenuItemBuilder}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, Emitter, Manager, }; +/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action). +/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode). +type ShortcutMap = Mutex>; + #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}!", name) @@ -54,14 +61,14 @@ async fn lastfm_request( client .get("https://ws.audioscrobbler.com/2.0/") .query(&map) - .header("User-Agent", "psysonic/1.6.0") + .header("User-Agent", "psysonic/1.13.0") .send() .await } else { client .post("https://ws.audioscrobbler.com/2.0/") .form(&map) - .header("User-Agent", "psysonic/1.6.0") + .header("User-Agent", "psysonic/1.13.0") .send() .await }.map_err(|e| e.to_string())?; @@ -76,11 +83,67 @@ async fn lastfm_request( } +#[tauri::command] +fn register_global_shortcut( + app: tauri::AppHandle, + shortcut_map: tauri::State, + shortcut: String, + action: String, +) -> Result<(), String> { + use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + + let mut map = shortcut_map.lock().unwrap(); + + // Idempotent: if this exact shortcut+action is already registered, skip. + // This prevents on_shortcut() from accumulating duplicate handlers when + // registerAll() is called again after a JS HMR reload or StrictMode double-effect. + if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) { + return Ok(()); + } + + // Unregister any existing OS grab for this shortcut before re-registering. + if let Ok(s) = shortcut.parse::() { + let _ = app.global_shortcut().unregister(s); + } + map.insert(shortcut.clone(), action.clone()); + drop(map); // release lock before the blocking OS call + + let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; + app.global_shortcut() + .on_shortcut(parsed, move |app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + let event_name = match action.as_str() { + "play-pause" => "media:play-pause", + "next" => "media:next", + "prev" => "media:prev", + "volume-up" => "media:volume-up", + "volume-down" => "media:volume-down", + _ => return, + }; + let _ = app.emit(event_name, ()); + } + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn unregister_global_shortcut( + app: tauri::AppHandle, + shortcut_map: tauri::State, + shortcut: String, +) -> Result<(), String> { + use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; + shortcut_map.lock().unwrap().remove(&shortcut); + let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; + app.global_shortcut().unregister(parsed).map_err(|e| e.to_string()) +} + pub fn run() { let (audio_engine, _audio_thread) = audio::create_engine(); tauri::Builder::default() .manage(audio_engine) + .manage(ShortcutMap::default()) .plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_notification::init()) @@ -138,8 +201,7 @@ pub fn run() { }) .build(app)?; - // Register media key global shortcuts - #[cfg(not(target_os = "linux"))] + // Register media key global shortcuts (all platforms) { use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; let shortcuts = ["MediaPlayPause", "MediaNextTrack", "MediaPreviousTrack"]; @@ -175,6 +237,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ greet, exit_app, + register_global_shortcut, + unregister_global_shortcut, audio::audio_play, audio::audio_pause, audio::audio_resume, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 44a36e33..002e0daf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.12.0", + "version": "1.13.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index b07621a3..ee777d2e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,7 @@ import { useThemeStore } from './store/themeStore'; import { useFontStore } from './store/fontStore'; import { useEqStore } from './store/eqStore'; import { useKeybindingsStore } from './store/keybindingsStore'; +import { useGlobalShortcutsStore } from './store/globalShortcutsStore'; function RequireAuth({ children }: { children: React.ReactNode }) { const { isLoggedIn, servers, activeServerId } = useAuthStore(); @@ -214,6 +215,9 @@ function TauriEventBridge() { const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + // Global shortcuts use modifier combos — skip in-app bindings for those + // (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView) + if (e.ctrlKey || e.altKey || e.metaKey) return; const { bindings } = useKeybindingsStore.getState(); const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState(); @@ -254,27 +258,41 @@ function TauriEventBridge() { }, []); useEffect(() => { + let cancelled = false; const unlisten: Array<() => void> = []; - listen('media:play-pause', () => togglePlay()).then(u => unlisten.push(u)); - listen('media:next', () => next()).then(u => unlisten.push(u)); - listen('media:prev', () => previous()).then(u => unlisten.push(u)); - listen('tray:play-pause', () => togglePlay()).then(u => unlisten.push(u)); - listen('tray:next', () => next()).then(u => unlisten.push(u)); - - // Handle close → minimize to tray if enabled (Tauri 2 approach) - const win = getCurrentWindow(); - win.onCloseRequested(async (event) => { - if (minimizeToTray) { - event.preventDefault(); - await win.hide(); - } else { - // If not minimizing to tray, we want to exit the app completely - await invoke('exit_app'); + const setup = async () => { + const handlers: Array<[string, () => void]> = [ + ['media:play-pause', () => togglePlay()], + ['media:next', () => next()], + ['media:prev', () => previous()], + ['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }], + ['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }], + ['tray:play-pause', () => togglePlay()], + ['tray:next', () => next()], + ]; + for (const [event, handler] of handlers) { + const u = await listen(event, handler); + if (cancelled) { u(); return; } + unlisten.push(u); } - }).then(u => unlisten.push(u)); - return () => unlisten.forEach(u => u()); + // Handle close → minimize to tray if enabled (Tauri 2 approach) + const win = getCurrentWindow(); + const u = await win.onCloseRequested(async (event) => { + if (minimizeToTray) { + event.preventDefault(); + await win.hide(); + } else { + await invoke('exit_app'); + } + }); + if (cancelled) { u(); return; } + unlisten.push(u); + }; + + setup(); + return () => { cancelled = true; unlisten.forEach(u => u()); }; }, [togglePlay, next, previous, minimizeToTray]); return null; @@ -296,6 +314,10 @@ export default function App() { return initAudioListeners(); }, []); + useEffect(() => { + useGlobalShortcutsStore.getState().registerAll(); + }, []); + return ( diff --git a/src/components/MarqueeText.tsx b/src/components/MarqueeText.tsx new file mode 100644 index 00000000..ba3323b2 --- /dev/null +++ b/src/components/MarqueeText.tsx @@ -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(null); + const textRef = useRef(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 ( +
+ 0 ? 'marquee-scroll' : ''} + style={scrollAmount > 0 ? { '--marquee-amount': `-${scrollAmount}px` } as React.CSSProperties : {}} + > + {text} + +
+ ); +} diff --git a/src/components/PSmallLogo.tsx b/src/components/PSmallLogo.tsx new file mode 100644 index 00000000..71911172 --- /dev/null +++ b/src/components/PSmallLogo.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +interface Props { + className?: string; + style?: React.CSSProperties; +} + +export default function PSmallLogo({ className, style }: Props) { + return ( + + + + + + + ); +} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index e44da4bb..eba497df 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -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() { )}
-
currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} - > - {currentTrack?.title ?? t('player.noTitle')} -
-
+ currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} - > - {currentTrack?.artist ?? '—'} -
+ />
{currentTrack && lastfmSessionKey && ( - +
+ {showVolPct && ( + + {Math.round(volume * 100)}% + + )} + setShowVolPct(true)} + onMouseLeave={() => setShowVolPct(false)} + /> +
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */} diff --git a/src/components/PsysonicLogo.tsx b/src/components/PsysonicLogo.tsx new file mode 100644 index 00000000..c655f6ad --- /dev/null +++ b/src/components/PsysonicLogo.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +interface Props { + className?: string; + style?: React.CSSProperties; +} + +export default function PsysonicLogo({ className, style }: Props) { + return ( + + + + + + + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 45497488..dc562052 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 = () => ( - Psysonic Logo -); +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 (