mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="115.549mm"
|
||||
height="130.30972mm"
|
||||
viewBox="0 0 115.549 130.30972"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="p-small.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"><inkscape:page
|
||||
x="0"
|
||||
y="0"
|
||||
width="115.549"
|
||||
height="130.30972"
|
||||
id="page2"
|
||||
margin="0"
|
||||
bleed="0" /></sodipodi:namedview><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Ebene 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(220.53237,27.789086)"><path
|
||||
style="fill:#ffffff"
|
||||
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>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
+68
-4
@@ -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<HashMap<String, String>>;
|
||||
|
||||
#[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<ShortcutMap>,
|
||||
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::<Shortcut>() {
|
||||
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<ShortcutMap>,
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+39
-17
@@ -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 (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 => (
|
||||
|
||||
+40
-8
@@ -355,6 +355,8 @@ const enTranslation = {
|
||||
shortcutsReset: 'Reset to defaults',
|
||||
shortcutListening: 'Press a key…',
|
||||
shortcutUnbound: '—',
|
||||
globalShortcutsTitle: 'Global Shortcuts',
|
||||
globalShortcutsNote: 'Work system-wide even when Psysonic is in the background. Requires Ctrl, Alt, or Super as a modifier.',
|
||||
shortcutClear: 'Clear',
|
||||
shortcutPlayPause: 'Play / Pause',
|
||||
shortcutNext: 'Next track',
|
||||
@@ -392,7 +394,7 @@ const enTranslation = {
|
||||
q4: 'How do I play music?',
|
||||
a4: 'Double-click any track to play it. On album and artist pages, use "Play All" to start the whole album. You can also drag tracks into the queue panel.',
|
||||
q5: 'What keyboard shortcuts are available?',
|
||||
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on macOS and Windows. On Linux, use the player bar buttons.',
|
||||
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on all platforms. On Linux, use the player bar buttons for media keys. In-app shortcuts and system-wide global shortcuts can be customized in Settings → Keyboard Shortcuts / Global Shortcuts.',
|
||||
q6: 'What is the queue?',
|
||||
a6: 'The queue shows all upcoming tracks. Open it with the panel icon in the top-right header (next to the Now Playing indicator). You can reorder tracks by dragging, shuffle with the shuffle button, and save the queue as a playlist.',
|
||||
q7: 'How do I open the fullscreen player?',
|
||||
@@ -408,7 +410,7 @@ const enTranslation = {
|
||||
a11: 'The banner at the top of the home page randomly picks albums from your library and rotates through them every 10 seconds. Click the dots to jump to a specific one, or click the banner to open the album.',
|
||||
s4: 'Settings',
|
||||
q12: 'How do I change the theme?',
|
||||
a12: 'Settings → Theme. Choose from 20+ themes across 6 families: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night, and the exclusive Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).',
|
||||
a12: 'Settings → Theme. Choose from 47 themes across 7 groups: Psysonic Themes, Psysonic Mediaplayer, Operating Systems, Games, Movies, Series, and Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||
q13: 'How do I change the language?',
|
||||
a13: 'Settings → Language. English, German, French, and Dutch are supported.',
|
||||
q14: 'What does "Minimize to Tray" do?',
|
||||
@@ -444,6 +446,12 @@ const enTranslation = {
|
||||
a21: 'This is usually a GPU/EGL driver issue in WebKitGTK. Launch with GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. The AUR package and official .deb/.rpm installers set these automatically.',
|
||||
q29: 'What are Crossfade and Gapless Playback?',
|
||||
a29: 'Both are experimental audio features in Settings → Audio. Gapless pre-buffers the next track to eliminate silence between songs. Crossfade fades out the current track while fading in the next — adjust the duration (1–10 s) to taste.',
|
||||
q30: 'Does Psysonic show lyrics?',
|
||||
a30: 'Yes. Click the microphone icon in the player bar to open the Lyrics tab in the queue panel. Psysonic fetches lyrics automatically from LRCLIB. If synced lyrics are available, the active line is highlighted and scrolls in real time as the song plays. If only plain text is available, the full text is shown statically.',
|
||||
q31: 'Can I customize keyboard shortcuts?',
|
||||
a31: 'Yes. Settings → Keyboard Shortcuts lets you remap in-app actions (Play/Pause, Next, Previous, Volume Up/Down, Fullscreen, and more) to any key. Settings → Global Shortcuts lets you assign system-wide shortcuts that trigger even when Psysonic is in the background.',
|
||||
q32: 'Can I change the font?',
|
||||
a32: 'Yes. Settings → Font lets you choose from 10 fonts including IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime, and others. The selected font applies to the entire UI.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Queue',
|
||||
@@ -886,6 +894,8 @@ const deTranslation = {
|
||||
shortcutListening: 'Taste drücken…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Löschen',
|
||||
globalShortcutsTitle: 'Globale Shortcuts',
|
||||
globalShortcutsNote: 'Funktionieren systemweit, auch wenn Psysonic im Hintergrund läuft. Mindestens Ctrl, Alt oder Super als Modifier erforderlich.',
|
||||
shortcutPlayPause: 'Wiedergabe / Pause',
|
||||
shortcutNext: 'Nächster Titel',
|
||||
shortcutPrev: 'Vorheriger Titel',
|
||||
@@ -923,7 +933,7 @@ const deTranslation = {
|
||||
q4: 'Wie spiele ich Musik ab?',
|
||||
a4: 'Doppelklick auf einen Track startet die Wiedergabe. Auf Album- und Künstlerseiten gibt es "Alle abspielen". Tracks lassen sich auch per Drag & Drop in die Warteschlange ziehen.',
|
||||
q5: 'Welche Tastenkürzel gibt es?',
|
||||
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren unter macOS und Windows. Unter Linux bitte die Buttons in der Playerleiste nutzen.',
|
||||
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren auf allen Plattformen. Unter Linux bitte die Buttons in der Playerleiste nutzen. In-App-Kürzel und systemweite globale Shortcuts können in Einstellungen → Tastenkürzel / Globale Shortcuts angepasst werden.',
|
||||
q6: 'Was ist die Warteschlange?',
|
||||
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Panel-Icon oben rechts im Header (neben dem Now-Playing-Indikator). Tracks per Drag & Drop umsortieren, mit dem Shuffle-Button mischen oder als Playlist speichern.',
|
||||
q7: 'Wie öffne ich den Vollbild-Player?',
|
||||
@@ -939,7 +949,7 @@ const deTranslation = {
|
||||
a11: 'Das Banner oben auf der Startseite wählt zufällige Alben aus der Bibliothek und rotiert alle 10 Sekunden weiter. Mit den Punkten kann man manuell springen, Klick auf das Banner öffnet das Album.',
|
||||
s4: 'Einstellungen',
|
||||
q12: 'Wie ändere ich das Theme?',
|
||||
a12: 'Einstellungen → Design. Über 20 Designs in 6 Familien: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night und die exklusiven Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).',
|
||||
a12: 'Einstellungen → Design. 47 Designs in 7 Gruppen: Psysonic Themes, Psysonic Mediaplayer, Betriebssysteme, Spiele, Filme, Serien und Open-Source-Classics (Catppuccin, Nord, Gruvbox).',
|
||||
q13: 'Wie ändere ich die Sprache?',
|
||||
a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch und Niederländisch werden unterstützt.',
|
||||
q14: 'Was bewirkt "In Tray minimieren"?',
|
||||
@@ -975,6 +985,12 @@ const deTranslation = {
|
||||
a21: 'Meist ein GPU/EGL-Treiberproblem in WebKitGTK. Mit GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 starten. Das AUR-Paket und die offiziellen .deb/.rpm-Pakete setzen diese Variablen automatisch.',
|
||||
q29: 'Was sind Crossfade und nahtlose Wiedergabe?',
|
||||
a29: 'Beides sind experimentelle Audiofunktionen in Einstellungen → Audio. Nahtlose Wiedergabe puffert den nächsten Track vor, um Stille zu eliminieren. Crossfade blendet den aktuellen Track aus, während der nächste eingeblendet wird — Dauer (1–10 s) einstellbar.',
|
||||
q30: 'Zeigt Psysonic Songtexte an?',
|
||||
a30: 'Ja. Klick auf das Mikrofon-Icon in der Playerleiste öffnet den Lyrics-Tab im Queue-Panel. Psysonic lädt Texte automatisch von LRCLIB. Sind synchronisierte Lyrics verfügbar, wird die aktuelle Zeile hervorgehoben und scrollt in Echtzeit mit. Bei reinen Texten wird der Volltext statisch angezeigt.',
|
||||
q31: 'Kann ich Tastenkürzel anpassen?',
|
||||
a31: 'Ja. Einstellungen → Tastenkürzel erlaubt das Neuzuordnen von In-App-Aktionen (Play/Pause, Weiter, Zurück, Lautstärke, Vollbild u. v. m.). Einstellungen → Globale Shortcuts ermöglicht systemweite Kürzel, die auch im Hintergrund ausgelöst werden.',
|
||||
q32: 'Kann ich die Schriftart ändern?',
|
||||
a32: 'Ja. Einstellungen → Schriftart bietet 10 Schriften zur Auswahl, darunter IBM Plex Mono, Fira Code, JetBrains Mono und Courier Prime. Die gewählte Schrift gilt für die gesamte Oberfläche.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Warteschlange',
|
||||
@@ -1417,6 +1433,8 @@ const frTranslation = {
|
||||
shortcutListening: 'Appuyez sur une touche…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Effacer',
|
||||
globalShortcutsTitle: 'Raccourcis globaux',
|
||||
globalShortcutsNote: 'Fonctionnent à l\'échelle du système, même quand Psysonic est en arrière-plan. Nécessite Ctrl, Alt ou Super comme modificateur.',
|
||||
shortcutPlayPause: 'Lecture / Pause',
|
||||
shortcutNext: 'Piste suivante',
|
||||
shortcutPrev: 'Piste précédente',
|
||||
@@ -1454,7 +1472,7 @@ const frTranslation = {
|
||||
q4: 'Comment lire de la musique ?',
|
||||
a4: 'Double-cliquez sur une piste pour la lire. Sur les pages d\'album et d\'artiste, utilisez « Tout lire » pour démarrer l\'album entier. Vous pouvez aussi glisser des pistes dans la file d\'attente.',
|
||||
q5: 'Quels raccourcis clavier sont disponibles ?',
|
||||
a5: 'Espace = Lecture / Pause · Échap = Fermer le lecteur plein écran. Les touches multimédia fonctionnent sur macOS et Windows. Sur Linux, utilisez les boutons de la barre du lecteur.',
|
||||
a5: 'Espace = Lecture / Pause · Échap = Fermer le lecteur plein écran. Les touches multimédia fonctionnent sur toutes les plateformes. Sur Linux, utilisez les boutons de la barre du lecteur. Les raccourcis in-app et les raccourcis système peuvent être personnalisés dans Paramètres → Raccourcis / Raccourcis globaux.',
|
||||
q6: 'Qu\'est-ce que la file d\'attente ?',
|
||||
a6: 'La file d\'attente affiche toutes les pistes à venir. Ouvrez-la avec l\'icône de panneau en haut à droite. Vous pouvez réorganiser les pistes par glisser-déposer, les mélanger et enregistrer la file comme liste de lecture.',
|
||||
q7: 'Comment ouvrir le lecteur plein écran ?',
|
||||
@@ -1470,7 +1488,7 @@ const frTranslation = {
|
||||
a11: 'La bannière en haut de la page d\'accueil sélectionne aléatoirement des albums de votre bibliothèque et les fait défiler toutes les 10 secondes. Cliquez sur les points pour accéder à un album spécifique.',
|
||||
s4: 'Paramètres',
|
||||
q12: 'Comment changer le thème ?',
|
||||
a12: 'Paramètres → Thème. Plus de 20 thèmes en 6 familles : Catppuccin, Nord, Gruvbox (Retro), Tokyo Night et les thèmes exclusifs Psysonic (Classic Winamp, Poison, Nucleo, Psychowave).',
|
||||
a12: 'Paramètres → Thème. 47 thèmes en 7 groupes : Psysonic Themes, Psysonic Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries et Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||
q13: 'Comment changer la langue ?',
|
||||
a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français et le néerlandais sont pris en charge.',
|
||||
q14: 'Que fait « Réduire dans la barre système » ?',
|
||||
@@ -1506,6 +1524,12 @@ const frTranslation = {
|
||||
a21: 'Cela est généralement dû à des problèmes de pilote GPU/EGL dans WebKitGTK. Lancez avec GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Les paquets AUR et installateurs .deb/.rpm officiels le configurent automatiquement.',
|
||||
q29: 'Que sont le fondu enchaîné et la lecture sans blanc ?',
|
||||
a29: 'Ce sont des fonctionnalités audio expérimentales dans Paramètres → Audio. La lecture sans blanc pré-charge la piste suivante pour éliminer les silences. Le fondu enchaîné effectue un fondu sortant de la piste en cours tout en faisant un fondu entrant de la suivante — durée réglable (1–10 s).',
|
||||
q30: 'Psysonic affiche-t-il les paroles ?',
|
||||
a30: 'Oui. Cliquez sur l\'icône microphone dans la barre du lecteur pour ouvrir l\'onglet Paroles. Psysonic récupère automatiquement les paroles depuis LRCLIB. Si des paroles synchronisées sont disponibles, la ligne active est mise en évidence et défile en temps réel. Sinon, le texte complet est affiché statiquement.',
|
||||
q31: 'Puis-je personnaliser les raccourcis clavier ?',
|
||||
a31: 'Oui. Paramètres → Raccourcis clavier permet de réattribuer les actions in-app (Lecture/Pause, Suivant, Précédent, Volume, Plein écran, etc.). Paramètres → Raccourcis globaux permet d\'assigner des raccourcis système actifs même quand l\'application est en arrière-plan.',
|
||||
q32: 'Puis-je changer la police ?',
|
||||
a32: 'Oui. Paramètres → Police propose 10 polices dont IBM Plex Mono, Fira Code, JetBrains Mono et Courier Prime. La police choisie s\'applique à toute l\'interface.',
|
||||
},
|
||||
queue: {
|
||||
title: 'File d\'attente',
|
||||
@@ -1948,6 +1972,8 @@ const nlTranslation = {
|
||||
shortcutListening: 'Druk op een toets…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Wissen',
|
||||
globalShortcutsTitle: 'Globale sneltoetsen',
|
||||
globalShortcutsNote: 'Werken systeembreed, ook als Psysonic op de achtergrond draait. Vereist Ctrl, Alt of Super als modifier.',
|
||||
shortcutPlayPause: 'Afspelen / Pauzeren',
|
||||
shortcutNext: 'Volgend nummer',
|
||||
shortcutPrev: 'Vorig nummer',
|
||||
@@ -1985,7 +2011,7 @@ const nlTranslation = {
|
||||
q4: 'Hoe speel ik muziek af?',
|
||||
a4: 'Dubbelklik op een nummer om het af te spelen. Gebruik op album- en artiestpagina\'s "Alles afspelen" om het hele album te starten. Je kunt ook nummers naar de wachtrij slepen.',
|
||||
q5: 'Welke sneltoetsen zijn beschikbaar?',
|
||||
a5: 'Spatie = Afspelen / Pauzeren · Escape = Volledig scherm sluiten. Mediatoetsen werken op macOS en Windows. Gebruik op Linux de knoppen van de spelerbalk.',
|
||||
a5: 'Spatie = Afspelen / Pauzeren · Escape = Volledig scherm sluiten. Mediatoetsen werken op alle platforms. Gebruik op Linux de knoppen van de spelerbalk. In-app sneltoetsen en systeembrede globale sneltoetsen zijn aan te passen via Instellingen → Sneltoetsen / Globale sneltoetsen.',
|
||||
q6: 'Wat is de wachtrij?',
|
||||
a6: 'De wachtrij toont alle komende nummers. Open deze met het paneelpictogram rechtsboven. Je kunt nummers herordenen door te slepen, shufflen en de wachtrij opslaan als afspeellijst.',
|
||||
q7: 'Hoe open ik de volledig-schermspeler?',
|
||||
@@ -2001,7 +2027,7 @@ const nlTranslation = {
|
||||
a11: 'De banner bovenaan de startpagina kiest willekeurig albums uit je bibliotheek en wisselt deze elke 10 seconden af. Klik op de puntjes om naar een specifiek album te springen.',
|
||||
s4: 'Instellingen',
|
||||
q12: 'Hoe verander ik het thema?',
|
||||
a12: 'Instellingen → Thema. Meer dan 20 thema\'s in 6 families: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night en de exclusieve Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).',
|
||||
a12: 'Instellingen → Thema. 47 thema\'s in 7 groepen: Psysonic Themes, Psysonic Mediaplayer, Besturingssystemen, Games, Films, Series en Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||
q13: 'Hoe verander ik de taal?',
|
||||
a13: 'Instellingen → Taal. Engels, Duits, Frans en Nederlands worden momenteel ondersteund.',
|
||||
q14: 'Wat doet "Minimaliseren naar systeemvak"?',
|
||||
@@ -2037,6 +2063,12 @@ const nlTranslation = {
|
||||
a21: 'Dit is meestal een GPU/EGL-driverprobleem in WebKitGTK. Start met GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Het AUR-pakket en officiële .deb/.rpm-installatieprogramma\'s doen dit automatisch.',
|
||||
q29: 'Wat zijn overgang (crossfade) en naadloos afspelen?',
|
||||
a29: 'Dit zijn experimentele audiofuncties in Instellingen → Audio. Naadloos afspelen buffert het volgende nummer voor om stiltes te elimineren. Overgang fadeert het huidige nummer uit terwijl het volgende infadeert — duur (1–10 s) instelbaar.',
|
||||
q30: 'Toont Psysonic songteksten?',
|
||||
a30: 'Ja. Klik op het microfoonpictogram in de spelerbalk om het tabblad Songteksten te openen. Psysonic haalt teksten automatisch op van LRCLIB. Als gesynchroniseerde teksten beschikbaar zijn, wordt de actieve regel gemarkeerd en scrolt mee in realtime. Anders wordt de volledige tekst statisch getoond.',
|
||||
q31: 'Kan ik sneltoetsen aanpassen?',
|
||||
a31: 'Ja. Instellingen → Sneltoetsen laat je in-app acties (Afspelen/Pauzeren, Volgende, Vorige, Volume, Volledig scherm, enz.) hertoewijzen. Instellingen → Globale sneltoetsen laat je systeembrede sneltoetsen instellen die werken ook als de app op de achtergrond staat.',
|
||||
q32: 'Kan ik het lettertype wijzigen?',
|
||||
a32: 'Ja. Instellingen → Lettertype biedt 10 lettertypen waaronder IBM Plex Mono, Fira Code, JetBrains Mono en Courier Prime. Het gekozen lettertype geldt voor de gehele interface.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Wachtrij',
|
||||
|
||||
+3
-1
@@ -43,9 +43,9 @@ export default function Help() {
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
{ q: t('help.q22'), a: t('help.a22') },
|
||||
{ q: t('help.q23'), a: t('help.a23') },
|
||||
{ q: t('help.q24'), a: t('help.a24') },
|
||||
{ q: t('help.q29'), a: t('help.a29') },
|
||||
{ q: t('help.q30'), a: t('help.a30') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -66,6 +66,8 @@ export default function Help() {
|
||||
{ q: t('help.q13'), a: t('help.a13') },
|
||||
{ q: t('help.q14'), a: t('help.a14') },
|
||||
{ q: t('help.q15'), a: t('help.a15') },
|
||||
{ q: t('help.q31'), a: t('help.a31') },
|
||||
{ q: t('help.q32'), a: t('help.a32') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+92
-6
@@ -15,6 +15,7 @@ import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -87,12 +88,14 @@ export default function Settings() {
|
||||
const theme = useThemeStore();
|
||||
const fontStore = useFontStore();
|
||||
const kb = useKeybindingsStore();
|
||||
const gs = useGlobalShortcutsStore();
|
||||
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
|
||||
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { state: routeState } = useLocation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'playback');
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'server');
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
@@ -295,11 +298,8 @@ export default function Settings() {
|
||||
{/* Crossfade */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.crossfade')}
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
{t('settings.experimental')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
|
||||
</div>
|
||||
@@ -448,7 +448,7 @@ export default function Settings() {
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} style={{
|
||||
<span key={genre} className="genre-keyword-badge" style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
@@ -545,6 +545,7 @@ export default function Settings() {
|
||||
|
||||
{/* ── Shortcuts ────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'shortcuts' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Keyboard size={18} />
|
||||
@@ -599,6 +600,7 @@ export default function Settings() {
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
@@ -626,6 +628,90 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Keyboard size={18} />
|
||||
<h2>{t('settings.globalShortcutsTitle')}</h2>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '12px', lineHeight: 1.5 }}>
|
||||
{t('settings.globalShortcutsNote')}
|
||||
</p>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
|
||||
{t('settings.shortcutsReset')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{([
|
||||
['play-pause', t('settings.shortcutPlayPause')],
|
||||
['next', t('settings.shortcutNext')],
|
||||
['prev', t('settings.shortcutPrev')],
|
||||
['volume-up', t('settings.shortcutVolumeUp')],
|
||||
['volume-down', t('settings.shortcutVolumeDown')],
|
||||
] as [GlobalAction, string][]).map(([action, label]) => {
|
||||
const bound = gs.shortcuts[action] ?? null;
|
||||
const isListening = listeningForGlobal === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningForGlobal(null); return; }
|
||||
setListeningForGlobal(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const shortcut = buildGlobalShortcut(e);
|
||||
if (shortcut) {
|
||||
gs.setShortcut(action, shortcut);
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatGlobalShortcut(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => gs.setShortcut(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Server ───────────────────────────────────────────────────────────── */}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { formatKeyCode } from './keybindingsStore';
|
||||
|
||||
export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down';
|
||||
|
||||
const MODIFIER_CODES = [
|
||||
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
|
||||
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
|
||||
];
|
||||
|
||||
/** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
|
||||
export function buildGlobalShortcut(e: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.includes(e.code)) return null;
|
||||
// Require at least Ctrl, Alt, or Meta — Shift alone is too invasive
|
||||
if (!e.ctrlKey && !e.altKey && !e.metaKey) return null;
|
||||
|
||||
const mods: string[] = [];
|
||||
if (e.ctrlKey) mods.push('ctrl');
|
||||
if (e.altKey) mods.push('alt');
|
||||
if (e.shiftKey) mods.push('shift');
|
||||
if (e.metaKey) mods.push('super');
|
||||
|
||||
return [...mods, e.code].join('+');
|
||||
}
|
||||
|
||||
/** Human-readable label for a stored shortcut string, e.g. "ctrl+alt+ArrowRight" → "Ctrl+Alt+→". */
|
||||
export function formatGlobalShortcut(shortcut: string): string {
|
||||
return shortcut.split('+').map(part => {
|
||||
if (part === 'ctrl') return 'Ctrl';
|
||||
if (part === 'alt') return 'Alt';
|
||||
if (part === 'shift') return 'Shift';
|
||||
if (part === 'super' || part === 'meta') return 'Super';
|
||||
return formatKeyCode(part);
|
||||
}).join('+');
|
||||
}
|
||||
|
||||
// Module-level guard — prevents double-registration from React StrictMode's
|
||||
// intentional double-invocation of effects in development.
|
||||
let _registerAllCalled = false;
|
||||
|
||||
interface GlobalShortcutsState {
|
||||
shortcuts: Partial<Record<GlobalAction, string>>;
|
||||
setShortcut: (action: GlobalAction, shortcut: string | null) => Promise<void>;
|
||||
registerAll: () => Promise<void>;
|
||||
resetAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useGlobalShortcutsStore = create<GlobalShortcutsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
shortcuts: {},
|
||||
|
||||
setShortcut: async (action, shortcut) => {
|
||||
const prev = get().shortcuts[action];
|
||||
if (prev) {
|
||||
try { await invoke('unregister_global_shortcut', { shortcut: prev }); } catch {}
|
||||
}
|
||||
if (shortcut) {
|
||||
try {
|
||||
await invoke('register_global_shortcut', { shortcut, action });
|
||||
set(s => ({ shortcuts: { ...s.shortcuts, [action]: shortcut } }));
|
||||
} catch (e) {
|
||||
console.warn('[GlobalShortcuts] Failed to register:', shortcut, e);
|
||||
}
|
||||
} else {
|
||||
set(s => {
|
||||
const next = { ...s.shortcuts };
|
||||
delete next[action];
|
||||
return { shortcuts: next };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
registerAll: async () => {
|
||||
if (_registerAllCalled) return;
|
||||
_registerAllCalled = true;
|
||||
const { shortcuts } = get();
|
||||
for (const [action, shortcut] of Object.entries(shortcuts)) {
|
||||
if (shortcut) {
|
||||
try {
|
||||
await invoke('register_global_shortcut', { shortcut, action });
|
||||
} catch (e) {
|
||||
console.warn('[GlobalShortcuts] Failed to re-register:', shortcut, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
resetAll: async () => {
|
||||
const { shortcuts } = get();
|
||||
for (const shortcut of Object.values(shortcuts)) {
|
||||
if (shortcut) {
|
||||
try { await invoke('unregister_global_shortcut', { shortcut }); } catch {}
|
||||
}
|
||||
}
|
||||
set({ shortcuts: {} });
|
||||
},
|
||||
}),
|
||||
{ name: 'psysonic_global_shortcuts' }
|
||||
)
|
||||
);
|
||||
+68
-18
@@ -38,7 +38,7 @@
|
||||
flex-direction: column;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
padding: var(--space-1) var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
@@ -176,7 +176,7 @@
|
||||
/* Collapsed Sidebar Styles */
|
||||
.sidebar.collapsed .sidebar-brand {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-link {
|
||||
@@ -185,21 +185,34 @@
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -13px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 101;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.sidebar:hover .collapse-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ─── Update Toast ─── */
|
||||
@@ -311,7 +324,7 @@
|
||||
grid-area: player;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-6);
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--bg-player);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
@@ -324,9 +337,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex: 0 0 auto;
|
||||
max-width: min(40vw, 480px);
|
||||
flex: 0 0 320px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-album-art {
|
||||
@@ -385,6 +398,7 @@
|
||||
.player-album-art-wrap.clickable:hover .player-art-expand-hint { opacity: 1; }
|
||||
|
||||
.player-track-meta {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -393,20 +407,33 @@
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-track-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Marquee (PlayerBar track name / artist) ── */
|
||||
.marquee-wrap {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.marquee-scroll {
|
||||
display: inline-block;
|
||||
animation: player-marquee 12s linear infinite;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
@keyframes player-marquee {
|
||||
0%, 12% { transform: translateX(0); }
|
||||
78%, 88% { transform: translateX(var(--marquee-amount)); }
|
||||
88.1%, 100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -462,6 +489,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: 32px;
|
||||
margin-right: 32px;
|
||||
}
|
||||
|
||||
.player-waveform-wrap {
|
||||
@@ -486,9 +515,30 @@
|
||||
width: 155px;
|
||||
}
|
||||
|
||||
.player-volume-slider {
|
||||
.player-volume-slider-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-volume-slider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-volume-pct {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.player-eq-btn {
|
||||
|
||||
+117
-4
@@ -1900,6 +1900,10 @@
|
||||
border-color: #222222;
|
||||
}
|
||||
|
||||
[data-theme='doh-matic'] .btn-surface:hover {
|
||||
background: #D0E5FF;
|
||||
}
|
||||
|
||||
/* ─── Order of the Phoenix ─── */
|
||||
[data-theme='order-of-the-phoenix'] {
|
||||
color-scheme: dark;
|
||||
@@ -1997,7 +2001,7 @@
|
||||
--ctp-mantle: #a0a0a0;
|
||||
--ctp-base: #c0c0c0;
|
||||
--ctp-surface0: #dfdfdf;
|
||||
--ctp-surface1: #ffffff;
|
||||
--ctp-surface1: #e0e0e0;
|
||||
--ctp-surface2: #000080;
|
||||
--ctp-overlay0: #808080;
|
||||
--ctp-overlay1: #404040;
|
||||
@@ -2052,7 +2056,7 @@
|
||||
[data-theme='w98'] .sidebar,
|
||||
[data-theme='w98'] .player-bar,
|
||||
[data-theme='w98'] .btn-surface {
|
||||
border: 1px solid #ffffff !important;
|
||||
border: 1px solid #e0e0e0 !important;
|
||||
border-right-color: #808080 !important;
|
||||
border-bottom-color: #808080 !important;
|
||||
box-shadow: 1px 1px 0 0 #000, inset 1px 1px 0 0 #dfdfdf !important;
|
||||
@@ -2060,7 +2064,7 @@
|
||||
|
||||
[data-theme='w98'] .nav-link.active {
|
||||
background-color: #000080 !important;
|
||||
color: #ffffff !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
[data-theme='w98'] .nav-link:hover:not(.active) {
|
||||
@@ -2081,12 +2085,121 @@
|
||||
|
||||
[data-theme='w98'] ::-webkit-scrollbar-thumb {
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-right-color: #808080;
|
||||
border-bottom-color: #808080;
|
||||
box-shadow: 1px 1px 0 0 #000;
|
||||
}
|
||||
|
||||
/* ── W98: contrast & hover fixes ── */
|
||||
/* Queue round buttons default bg uses --bg-hover (#000080) → swap to silver */
|
||||
[data-theme='w98'] .queue-round-btn {
|
||||
background: #d4d0c8;
|
||||
color: #000000;
|
||||
}
|
||||
[data-theme='w98'] .queue-round-btn:hover:not(:disabled) {
|
||||
background: #000080;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
[data-theme='w98'] .queue-round-btn.active {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* btn-surface hover: light W98 grey instead of navy */
|
||||
[data-theme='w98'] .btn-surface:hover {
|
||||
background: #d4d0c8 !important;
|
||||
color: #000000;
|
||||
}
|
||||
/* Queue tab hover */
|
||||
[data-theme='w98'] .queue-tab-btn:hover {
|
||||
background: #d4d0c8;
|
||||
color: #000000;
|
||||
}
|
||||
/* Hero eyebrow + meta badges: --accent (#000080) invisible on dark hero overlay */
|
||||
[data-theme='w98'] .hero-eyebrow {
|
||||
color: rgba(224, 224, 224, 0.85);
|
||||
}
|
||||
[data-theme='w98'] .hero-meta .badge {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* Hero play/enqueue buttons */
|
||||
[data-theme='w98'] .hero-play-btn {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* Settings nav tabs hover */
|
||||
[data-theme='w98'] .settings-tab:hover { color: #e0e0e0; }
|
||||
/* Theme accordion: hover + open-state both use navy bg */
|
||||
[data-theme='w98'] .theme-accordion-header:hover { color: #e0e0e0; }
|
||||
[data-theme='w98'] .theme-accordion-open .theme-accordion-header { color: #e0e0e0; }
|
||||
[data-theme='w98'] .theme-accordion-open .theme-accordion-chevron { color: rgba(224, 224, 224, 0.7); }
|
||||
/* Language dropdown hover */
|
||||
[data-theme='w98'] .custom-select-trigger:hover { color: #e0e0e0; }
|
||||
[data-theme='w98'] .custom-select-option:hover { color: #e0e0e0; }
|
||||
/* Genre filter keywords / artist ext links hover (!important — inline style wins otherwise) */
|
||||
[data-theme='w98'] .artist-ext-link:hover { color: #e0e0e0 !important; }
|
||||
/* Changelog entries hover */
|
||||
[data-theme='w98'] .changelog-summary:hover { color: #e0e0e0; }
|
||||
/* Keybind/global-shortcut badge buttons: --bg-hover (#000080) as static bg */
|
||||
[data-theme='w98'] .keybind-badge { color: #e0e0e0 !important; }
|
||||
|
||||
/* ── W98: remaining navy-hover contrast fixes ── */
|
||||
/* Header toggle button (queue/sidebar) */
|
||||
[data-theme='w98'] .collapse-btn:hover { color: #e0e0e0; }
|
||||
/* Album section nav arrows */
|
||||
[data-theme='w98'] .album-row-nav .nav-btn:hover:not(.disabled) { color: #e0e0e0; }
|
||||
/* Live search results */
|
||||
[data-theme='w98'] .search-result-item:hover .search-result-name,
|
||||
[data-theme='w98'] .search-result-item:hover .search-result-sub,
|
||||
[data-theme='w98'] .search-result-item.active .search-result-name,
|
||||
[data-theme='w98'] .search-result-item.active .search-result-sub { color: #e0e0e0; }
|
||||
/* Tracklist rows — many children have explicit colors, override all */
|
||||
[data-theme='w98'] .track-row:hover *,
|
||||
[data-theme='w98'] .track-row.context-active * { color: #e0e0e0; }
|
||||
/* Artist list rows */
|
||||
[data-theme='w98'] .artist-row:hover { color: #e0e0e0; }
|
||||
/* Year/decade toggle */
|
||||
[data-theme='w98'] .year-toggle:hover { color: #e0e0e0; }
|
||||
/* Playlist rows */
|
||||
[data-theme='w98'] .playlist-row:hover,
|
||||
[data-theme='w98'] .playlist-row:hover * { color: #e0e0e0; }
|
||||
/* Help/FAQ */
|
||||
[data-theme='w98'] .help-question:hover { color: #e0e0e0; }
|
||||
[data-theme='w98'] .help-item-open .help-question { color: #e0e0e0; }
|
||||
[data-theme='w98'] .help-item-open .help-chevron { color: rgba(224, 224, 224, 0.7); }
|
||||
/* Now Playing action buttons + queue items */
|
||||
[data-theme='w98'] .np-action-btn:hover { color: #e0e0e0; }
|
||||
[data-theme='w98'] .np-queue-item:hover,
|
||||
[data-theme='w98'] .np-queue-item.context-active { color: #e0e0e0; }
|
||||
/* EQ buttons: static navy bg → swap to silver; hover → navy + light grey */
|
||||
[data-theme='w98'] .eq-ctrl-btn { background: #d4d0c8; color: #000000; }
|
||||
[data-theme='w98'] .eq-ctrl-btn:hover { background: #000080; color: #e0e0e0; }
|
||||
/* Changelog inline code: navy bg + navy text = invisible → silver bg + navy text */
|
||||
[data-theme='w98'] .changelog-code { background: #d4d0c8; color: #000080; }
|
||||
/* Ghost buttons (e.g. "Als Favorit") — !important needed, inline color style wins otherwise */
|
||||
[data-theme='w98'] .btn-ghost:hover { color: #e0e0e0 !important; }
|
||||
/* Built-in keyword chips (inline style uses --bg-hover) */
|
||||
[data-theme='w98'] .genre-keyword-badge { background: #d4d0c8 !important; color: #404040 !important; }
|
||||
/* Toggle switch knob: hardcoded white → W98 silver-grey */
|
||||
[data-theme='w98'] .toggle-track::before { background: #e0e0e0; }
|
||||
/* Now Playing: song title in W98 teal (inline style needs !important) */
|
||||
[data-theme='w98'] .np-title { color: #008080 !important; }
|
||||
/* Queue items: keep navy hover, light grey text */
|
||||
[data-theme='w98'] .queue-item:hover .queue-item-title,
|
||||
[data-theme='w98'] .queue-item:hover .queue-item-artist,
|
||||
[data-theme='w98'] .queue-item:hover .queue-item-duration,
|
||||
[data-theme='w98'] .queue-item.context-active .queue-item-title,
|
||||
[data-theme='w98'] .queue-item.context-active .queue-item-artist,
|
||||
[data-theme='w98'] .queue-item.context-active .queue-item-duration {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* Player bar buttons: keep navy hover, light grey icon */
|
||||
[data-theme='w98'] .player-btn:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
[data-theme='w98'] .player-btn-primary:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ─── Global Base Settings ─── */
|
||||
:root {
|
||||
/* Typography */
|
||||
|
||||
Reference in New Issue
Block a user