feat: v1.25.0 — Tray Icon, Minimize to Tray, Sidebar Customization

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-01 09:46:41 +02:00
parent ada5327493
commit 560349819f
22 changed files with 516 additions and 114 deletions
+17
View File
@@ -5,6 +5,23 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.25.0] - 2026-04-01
### Added
- **System Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: Functional tray icon with context menu — Play / Pause, Previous Track, Next Track, Show / Hide, and Exit Psysonic. Left-clicking the tray icon toggles window visibility. The tray icon is built via `TrayIconBuilder` in Rust so menu events are properly wired.
- **Minimize to Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: New toggle in Settings → Behavior. When enabled, closing the window hides it to the tray instead of exiting. The close button behaviour is intercepted in Rust (`prevent_close` + `window:close-requested` event) and the JS side decides hide vs. exit based on the user setting.
- **Sidebar Customization** *(requested by [@lighthous3d](https://github.com/lighthous3d))*: New section in Settings → Appearance. All library and system nav items can be shown/hidden via a toggle switch and reordered by dragging the grip handle. Order and visibility are persisted across sessions (`psysonic_sidebar` in localStorage). Fixed items (Now Playing, Settings) are listed as non-configurable below the list.
- **Playlist cover art**: Playlist cards on the Playlists overview page now display the server-generated cover image (Navidrome's `coverArt` field on the playlist object) via the IndexedDB image cache. Falls back to the ListMusic icon when no cover is available.
### Fixed
- **Cover image flickering**: `buildCoverArtUrl()` generates a new random auth salt on every call, causing `useCachedUrl` to re-trigger on every render and produce a rapid re-fetch loop. Fixed by wrapping all `buildCoverArtUrl` / `coverArtCacheKey` calls in `useMemo` with the cover ID as dependency in `ArtistCardLocal`, `QueuePanel`, `FullscreenPlayer`, `Hero`, and `PlaylistDetail`.
- **DnD text selection**: Dragging a grip handle in the Sidebar Customizer (and any future `useDragSource` consumer) would select all text on the page during the threshold detection phase. Fixed by calling `e.preventDefault()` in `useDragSource`'s `onMouseDown` handler before the drag threshold is reached.
- **Sidebar Customization DnD on Linux**: The initial implementation used the HTML5 Drag & Drop API, which always shows a forbidden cursor on WebKitGTK and does not fire drop events reliably. Rewritten to use the existing psy-drag mouse-event system (`useDragSource` / `psy-drop` custom event), consistent with the Queue panel.
---
## [1.24.0] - 2026-03-31 ## [1.24.0] - 2026-03-31
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.24.0", "version": "1.25.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de> # Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic pkgname=psysonic
pkgver=1.24.0 pkgver=1.25.0
pkgrel=1 pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.24.0" version = "1.25.0"
dependencies = [ dependencies = [
"biquad", "biquad",
"md5", "md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.24.0" version = "1.25.0"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+76 -1
View File
@@ -6,7 +6,11 @@ mod audio;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Emitter, Manager}; use tauri::{
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action). /// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode). /// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
@@ -300,6 +304,68 @@ pub fn run() {
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.setup(|app| { .setup(|app| {
// ── System tray ───────────────────────────────────────────────
{
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { std::process::exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
// Left-click: toggle window visibility
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)?;
}
// ── MPRIS2 / OS media controls via souvlaki ────────────────── // ── MPRIS2 / OS media controls via souvlaki ──────────────────
{ {
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig}; use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
@@ -392,6 +458,15 @@ pub fn run() {
Ok(()) Ok(())
}) })
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
// Let JS decide: minimize to tray or exit, based on user setting.
api.prevent_close();
let _ = window.emit("window:close-requested", ());
}
}
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
greet, greet,
exit_app, exit_app,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.24.0", "version": "1.25.0",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+15 -8
View File
@@ -279,7 +279,7 @@ function AppShell() {
); );
} }
// Media key event handler // Media key + tray event handler
function TauriEventBridge() { function TauriEventBridge() {
const togglePlay = usePlayerStore(s => s.togglePlay); const togglePlay = usePlayerStore(s => s.togglePlay);
const next = usePlayerStore(s => s.next); const next = usePlayerStore(s => s.next);
@@ -338,9 +338,12 @@ function TauriEventBridge() {
const setup = async () => { const setup = async () => {
const handlers: Array<[string, () => void]> = [ const handlers: Array<[string, () => void]> = [
['media:play-pause', () => togglePlay()], ['media:play-pause', () => togglePlay()],
['media:next', () => next()], ['media:next', () => next()],
['media:prev', () => previous()], ['media:prev', () => previous()],
['tray:play-pause', () => togglePlay()],
['tray:next', () => next()],
['tray:previous', () => previous()],
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }], ['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)); }], ['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
]; ];
@@ -372,10 +375,14 @@ function TauriEventBridge() {
unlisten.push(u); unlisten.push(u);
} }
// Close → exit app // window:close-requested is emitted by Rust (prevent_close + emit).
const win = getCurrentWindow(); // JS decides: minimize to tray or exit, based on user setting.
const u = await win.onCloseRequested(async () => { const u = await listen('window:close-requested', async () => {
await invoke('exit_app'); if (useAuthStore.getState().minimizeToTray) {
await getCurrentWindow().hide();
} else {
await invoke('exit_app');
}
}); });
if (cancelled) { u(); return; } if (cancelled) { u(); return; }
unlisten.push(u); unlisten.push(u);
+1
View File
@@ -96,6 +96,7 @@ export interface SubsonicPlaylist {
changed: string; changed: string;
owner?: string; owner?: string;
public?: boolean; public?: boolean;
coverArt?: string;
} }
export interface SubsonicNowPlaying extends SubsonicSong { export interface SubsonicNowPlaying extends SubsonicSong {
+7 -3
View File
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useMemo } from 'react';
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
@@ -11,14 +11,18 @@ interface Props {
export default function ArtistCardLocal({ artist }: Props) { export default function ArtistCardLocal({ artist }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const coverId = artist.coverArt || artist.id; const coverId = artist.coverArt || artist.id;
// buildCoverArtUrl generates a new crypto salt on every call — must be
// memoized to prevent a new URL on every parent re-render causing refetch loops.
const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]);
const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]);
return ( return (
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}> <div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
<div className="artist-card-avatar"> <div className="artist-card-avatar">
{coverId ? ( {coverId ? (
<CachedImage <CachedImage
src={buildCoverArtUrl(coverId, 300)} src={coverSrc}
cacheKey={coverArtCacheKey(coverId, 300)} cacheKey={coverCacheKey}
alt={artist.name} alt={artist.name}
loading="lazy" loading="lazy"
onError={(e) => { onError={(e) => {
+5 -3
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react'; import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
import { import {
Play, Pause, SkipBack, SkipForward, Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
@@ -154,8 +154,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const toggleQueue = usePlayerStore(s => s.toggleQueue); const toggleQueue = usePlayerStore(s => s.toggleQueue);
const duration = currentTrack?.duration ?? 0; const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; // buildCoverArtUrl generates a new salt on every call — must be memoized
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; // to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
// useCachedUrl must be called unconditionally (hook rules) // useCachedUrl must be called unconditionally (hook rules)
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
+6 -7
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react'; import { Play, ListPlus } from 'lucide-react';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic'; import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
@@ -92,9 +92,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
}); });
}, [album?.id]); }, [album?.id]);
// Resolve background URL via cache // buildCoverArtUrl generates a new salt on every call — must be memoized.
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : ''; const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]);
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : ''; const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]);
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey); const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
// Keep the last known good URL so HeroBg never receives '' during a cache-miss // Keep the last known good URL so HeroBg never receives '' during a cache-miss
@@ -102,9 +102,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const stableBgUrl = useRef(''); const stableBgUrl = useRef('');
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl; if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl;
// Resolve cover thumbnail via cache const coverRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '', [album?.coverArt]);
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : ''; const coverCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '', [album?.coverArt]);
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
if (!album) return <div className="hero-placeholder" />; if (!album) return <div className="hero-placeholder" />;
+6 -2
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
@@ -159,6 +159,10 @@ export default function QueuePanel() {
const queueIndex = usePlayerStore(s => s.queueIndex); const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const currentTime = usePlayerStore(s => s.currentTime); const currentTime = usePlayerStore(s => s.currentTime);
const currentCoverSrc = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
[currentTrack?.coverArt]
);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const playTrack = usePlayerStore(s => s.playTrack); const playTrack = usePlayerStore(s => s.playTrack);
const toggleQueue = usePlayerStore(s => s.toggleQueue); const toggleQueue = usePlayerStore(s => s.toggleQueue);
@@ -371,7 +375,7 @@ export default function QueuePanel() {
<div className="queue-current-track-body"> <div className="queue-current-track-body">
<div className="queue-current-cover"> <div className="queue-current-cover">
{currentTrack.coverArt ? ( {currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" /> <img src={currentCoverSrc} alt="" loading="eager" />
) : ( ) : (
<div className="fallback"><Music size={32} /></div> <div className="fallback"><Music size={32} /></div>
)} )}
+40 -32
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import { version as appVersion } from '../../package.json'; import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
@@ -13,17 +14,21 @@ import {
import PsysonicLogo from './PsysonicLogo'; import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo'; import PSmallLogo from './PSmallLogo';
const navItems = [ // All configurable nav items — order and visibility controlled by sidebarStore.
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' }, // Exported so Settings can render the same item metadata.
{ icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases' }, export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey: string; to: string; section: 'library' | 'system' }> = {
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' }, mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' }, newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' }, allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
{ icon: Tags, labelKey: 'sidebar.genres', to: '/genres' }, randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums', section: 'library' },
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' }, artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' }, genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' }, randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
]; favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};
function isNewer(latest: string, current: string): boolean { function isNewer(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^[^0-9]*/, '').split('.').map(Number); const parse = (v: string) => v.replace(/^[^0-9]*/, '').split('.').map(Number);
@@ -77,8 +82,17 @@ export default function Sidebar({
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const [latestVersion, setLatestVersion] = useState<string | null>(null); const [latestVersion, setLatestVersion] = useState<string | null>(null);
// Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
const visibleSystem = sidebarItems
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -122,7 +136,7 @@ export default function Sidebar({
<nav className="sidebar-nav" aria-label="Hauptnavigation"> <nav className="sidebar-nav" aria-label="Hauptnavigation">
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>} {!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
{navItems.map(item => ( {visibleLibrary.map(item => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -136,7 +150,7 @@ export default function Sidebar({
</NavLink> </NavLink>
))} ))}
{/* Now Playing — special styled */} {/* Now Playing — fixed, always visible */}
<NavLink <NavLink
to="/now-playing" to="/now-playing"
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`} className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
@@ -163,26 +177,20 @@ export default function Sidebar({
</NavLink> </NavLink>
)} )}
{!isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>} {visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />} {latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
<NavLink {visibleSystem.map(item => (
to="/statistics" <NavLink
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} key={item.to}
data-tooltip={isCollapsed ? t('sidebar.statistics') : undefined} to={item.to}
data-tooltip-pos="bottom" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
> data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
<BarChart3 size={isCollapsed ? 22 : 18} /> data-tooltip-pos="bottom"
{!isCollapsed && <span>{t('sidebar.statistics')}</span>} >
</NavLink> <item.icon size={isCollapsed ? 22 : 18} />
<NavLink {!isCollapsed && <span>{t(item.labelKey)}</span>}
to="/help" </NavLink>
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} ))}
data-tooltip={isCollapsed ? t('sidebar.help') : undefined}
data-tooltip-pos="bottom"
>
<HelpCircle size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.help')}</span>}
</NavLink>
<NavLink <NavLink
to="/settings" to="/settings"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
+3
View File
@@ -200,6 +200,9 @@ export function useDragSource(getPayload: () => DragPayload) {
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
// Only left-click // Only left-click
if (e.button !== 0) return; if (e.button !== 0) return;
// Prevent the browser from starting a text-selection drag during the
// threshold detection phase (mousedown → mousemove before startDrag).
e.preventDefault();
const startX = e.clientX; const startX = e.clientX;
const startY = e.clientY; const startY = e.clientY;
+30
View File
@@ -361,6 +361,8 @@ const enTranslation = {
cacheClearWarning: 'This will also remove all offline albums from the library.', cacheClearWarning: 'This will also remove all offline albums from the library.',
cacheClearConfirm: 'Clear Everything', cacheClearConfirm: 'Clear Everything',
cacheClearCancel: 'Cancel', cacheClearCancel: 'Cancel',
minimizeToTray: 'Minimize to Tray',
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
downloadsTitle: 'Download Folder', downloadsTitle: 'Download Folder',
downloadsDefault: 'Default Downloads Folder', downloadsDefault: 'Default Downloads Folder',
pickFolder: 'Select', pickFolder: 'Select',
@@ -390,6 +392,10 @@ const enTranslation = {
tabPlayback: 'Playback', tabPlayback: 'Playback',
tabLibrary: 'Library', tabLibrary: 'Library',
tabAppearance: 'Appearance', tabAppearance: 'Appearance',
sidebarTitle: 'Sidebar',
sidebarReset: 'Reset to default',
sidebarDrag: 'Drag to reorder',
sidebarFixed: 'Always visible',
tabShortcuts: 'Shortcuts', tabShortcuts: 'Shortcuts',
tabServer: 'Server', tabServer: 'Server',
tabAbout: 'About', tabAbout: 'About',
@@ -982,6 +988,8 @@ const deTranslation = {
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.', cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
cacheClearConfirm: 'Alles löschen', cacheClearConfirm: 'Alles löschen',
cacheClearCancel: 'Abbrechen', cacheClearCancel: 'Abbrechen',
minimizeToTray: 'Im Tray minimieren',
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
downloadsTitle: 'Download-Ordner', downloadsTitle: 'Download-Ordner',
downloadsDefault: 'Standard-Downloads-Ordner', downloadsDefault: 'Standard-Downloads-Ordner',
pickFolder: 'Auswählen', pickFolder: 'Auswählen',
@@ -1011,6 +1019,10 @@ const deTranslation = {
tabPlayback: 'Wiedergabe', tabPlayback: 'Wiedergabe',
tabLibrary: 'Bibliothek', tabLibrary: 'Bibliothek',
tabAppearance: 'Darstellung', tabAppearance: 'Darstellung',
sidebarTitle: 'Seitenleiste',
sidebarReset: 'Zurücksetzen',
sidebarDrag: 'Ziehen zum Umsortieren',
sidebarFixed: 'Immer sichtbar',
tabShortcuts: 'Tastenkürzel', tabShortcuts: 'Tastenkürzel',
tabServer: 'Server', tabServer: 'Server',
shortcutsReset: 'Auf Standard zurücksetzen', shortcutsReset: 'Auf Standard zurücksetzen',
@@ -1603,6 +1615,8 @@ const frTranslation = {
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.', cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
cacheClearConfirm: 'Tout supprimer', cacheClearConfirm: 'Tout supprimer',
cacheClearCancel: 'Annuler', cacheClearCancel: 'Annuler',
minimizeToTray: 'Réduire dans la barre système',
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
downloadsTitle: 'Dossier de téléchargement', downloadsTitle: 'Dossier de téléchargement',
downloadsDefault: 'Dossier de téléchargement par défaut', downloadsDefault: 'Dossier de téléchargement par défaut',
pickFolder: 'Sélectionner', pickFolder: 'Sélectionner',
@@ -1632,6 +1646,10 @@ const frTranslation = {
tabPlayback: 'Lecture', tabPlayback: 'Lecture',
tabLibrary: 'Bibliothèque', tabLibrary: 'Bibliothèque',
tabAppearance: 'Apparence', tabAppearance: 'Apparence',
sidebarTitle: 'Barre latérale',
sidebarReset: 'Réinitialiser',
sidebarDrag: 'Glisser pour réorganiser',
sidebarFixed: 'Toujours visible',
tabShortcuts: 'Raccourcis', tabShortcuts: 'Raccourcis',
tabServer: 'Serveur', tabServer: 'Serveur',
shortcutsReset: 'Réinitialiser', shortcutsReset: 'Réinitialiser',
@@ -2224,6 +2242,8 @@ const nlTranslation = {
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.', cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
cacheClearConfirm: 'Alles wissen', cacheClearConfirm: 'Alles wissen',
cacheClearCancel: 'Annuleren', cacheClearCancel: 'Annuleren',
minimizeToTray: 'Minimaliseren naar systeemvak',
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
downloadsTitle: 'Downloadmap', downloadsTitle: 'Downloadmap',
downloadsDefault: 'Standaard downloadmap', downloadsDefault: 'Standaard downloadmap',
pickFolder: 'Selecteren', pickFolder: 'Selecteren',
@@ -2253,6 +2273,10 @@ const nlTranslation = {
tabPlayback: 'Afspelen', tabPlayback: 'Afspelen',
tabLibrary: 'Bibliotheek', tabLibrary: 'Bibliotheek',
tabAppearance: 'Weergave', tabAppearance: 'Weergave',
sidebarTitle: 'Zijbalk',
sidebarReset: 'Standaard herstellen',
sidebarDrag: 'Slepen om te herordenen',
sidebarFixed: 'Altijd zichtbaar',
tabShortcuts: 'Sneltoetsen', tabShortcuts: 'Sneltoetsen',
tabServer: 'Server', tabServer: 'Server',
shortcutsReset: 'Standaard herstellen', shortcutsReset: 'Standaard herstellen',
@@ -2845,6 +2869,8 @@ const zhTranslation = {
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。', cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
cacheClearConfirm: '全部清除', cacheClearConfirm: '全部清除',
cacheClearCancel: '取消', cacheClearCancel: '取消',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
downloadsTitle: '下载文件夹', downloadsTitle: '下载文件夹',
downloadsDefault: '默认下载文件夹', downloadsDefault: '默认下载文件夹',
pickFolder: '选择', pickFolder: '选择',
@@ -2874,6 +2900,10 @@ const zhTranslation = {
tabPlayback: '播放', tabPlayback: '播放',
tabLibrary: '音乐库', tabLibrary: '音乐库',
tabAppearance: '外观', tabAppearance: '外观',
sidebarTitle: '侧边栏',
sidebarReset: '重置为默认',
sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示',
tabShortcuts: '快捷键', tabShortcuts: '快捷键',
tabServer: '服务器', tabServer: '服务器',
tabAbout: '关于', tabAbout: '关于',
+17 -18
View File
@@ -82,9 +82,18 @@ export default function PlaylistDetail() {
return result; return result;
}, [songs]); }, [songs]);
// One resolved URL for the blurred background (must be called unconditionally) // Stable fetch URLs + cache keys for the 2×2 grid and blurred background.
// useMemo is required here — buildCoverArtUrl generates a new salt on every call, // buildCoverArtUrl generates a new crypto salt on every call, so these MUST
// which would change bgFetchUrl every render and cause useCachedUrl to re-fetch in a loop. // be memoized — otherwise every render produces new URLs, useCachedUrl
// re-triggers, state updates, another render → infinite flicker loop.
const coverQuadUrls = useMemo(() =>
Array.from({ length: 4 }, (_, i) => {
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
if (!coverId) return null;
return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) };
}),
[coverQuad]);
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]); const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]); const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey); const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
@@ -318,21 +327,11 @@ export default function PlaylistDetail() {
<div className="album-detail-hero"> <div className="album-detail-hero">
{/* 2×2 cover grid */} {/* 2×2 cover grid */}
<div className="playlist-cover-grid"> <div className="playlist-cover-grid">
{Array.from({ length: 4 }, (_, i) => { {coverQuadUrls.map((entry, i) =>
const coverId = coverQuad[i % Math.max(1, coverQuad.length)]; entry
if (!coverId) { ? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
return <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />; : <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
} )}
return (
<CachedImage
key={i}
className="playlist-cover-cell"
src={buildCoverArtUrl(coverId, 200)}
cacheKey={coverArtCacheKey(coverId, 200)}
alt=""
/>
);
})}
</div> </div>
<div className="album-detail-meta"> <div className="album-detail-meta">
+15 -5
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, X } from 'lucide-react'; import { ListMusic, Play, Plus, X } from 'lucide-react';
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist } from '../api/subsonic'; import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
@@ -135,11 +136,20 @@ export default function Playlists() {
onClick={() => navigate(`/playlists/${pl.id}`)} onClick={() => navigate(`/playlists/${pl.id}`)}
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }} onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
> >
{/* Cover area — playlist SVG placeholder */} {/* Cover area — server collage or fallback icon */}
<div className="album-card-cover"> <div className="album-card-cover">
<div className="album-card-cover-placeholder playlist-card-icon"> {pl.coverArt ? (
<ListMusic size={48} strokeWidth={1.2} /> <CachedImage
</div> src={buildCoverArtUrl(pl.coverArt, 256)}
cacheKey={coverArtCacheKey(pl.coverArt, 256)}
alt={pl.name}
className="album-card-cover-img"
/>
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<ListMusic size={48} strokeWidth={1.2} />
</div>
)}
{/* Play overlay — same pattern as AlbumCard */} {/* Play overlay — same pattern as AlbumCard */}
<div className="album-card-play-overlay"> <div className="album-card-play-overlay">
+171 -30
View File
@@ -1,10 +1,11 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react'; import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { version as appVersion } from '../../package.json'; import { version as appVersion } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw'; import changelogRaw from '../../CHANGELOG.md?raw';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw
} from 'lucide-react'; } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell'; import { open as openUrl } from '@tauri-apps/plugin-shell';
@@ -19,6 +20,9 @@ import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore'; import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore'; import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore'; import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
import { ALL_NAV_ITEMS } from '../components/Sidebar';
import { pingWithCredentials } from '../api/subsonic'; import { pingWithCredentials } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -603,43 +607,34 @@ export default function Settings() {
</div> </div>
<div className="settings-card"> <div className="settings-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{([ {(
{ id: 'inter', label: 'Inter', sample: 'The quick brown fox' }, [
{ id: 'outfit', label: 'Outfit', sample: 'The quick brown fox' }, { id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" },
{ id: 'dm-sans', label: 'DM Sans', sample: 'The quick brown fox' }, { id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" },
{ id: 'nunito', label: 'Nunito', sample: 'The quick brown fox' }, { id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" },
{ id: 'rubik', label: 'Rubik', sample: 'The quick brown fox' }, { id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" },
{ id: 'space-grotesk', label: 'Space Grotesk', sample: 'The quick brown fox' }, { id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" },
{ id: 'figtree', label: 'Figtree', sample: 'The quick brown fox' }, { id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" },
{ id: 'manrope', label: 'Manrope', sample: 'The quick brown fox' }, { id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" },
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', sample: 'The quick brown fox' }, { id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" },
{ id: 'lexend', label: 'Lexend', sample: 'The quick brown fox' }, { id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" },
] as { id: FontId; label: string; sample: string }[]).map(f => ( { id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" },
] as { id: FontId; label: string; stack: string }[]
).map(f => (
<button <button
key={f.id} key={f.id}
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
onClick={() => fontStore.setFont(f.id)} onClick={() => fontStore.setFont(f.id)}
style={{
display: 'flex', alignItems: 'center', gap: '12px',
background: fontStore.font === f.id ? 'var(--accent-dim)' : 'transparent',
border: `1px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border-subtle)'}`,
borderRadius: 'var(--radius-md)', padding: '10px 14px',
cursor: 'pointer', textAlign: 'left', width: '100%',
}}
> >
<div style={{ {f.label}
width: 14, height: 14, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border)'}`,
background: fontStore.font === f.id ? 'var(--accent)' : 'transparent',
}} />
<div>
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{f.label}</div>
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{f.sample}</div>
</div>
</button> </button>
))} ))}
</div> </div>
</div> </div>
</section> </section>
<SidebarCustomizer />
</> </>
)} )}
@@ -972,6 +967,17 @@ export default function Settings() {
<h2>{t('settings.behavior')}</h2> <h2>{t('settings.behavior')}</h2>
</div> </div>
<div className="settings-card"> <div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <div>
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div> <div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
@@ -1132,6 +1138,141 @@ function renderInline(text: string): React.ReactNode[] {
}); });
} }
function SidebarGripHandle({ idx, label }: { idx: number; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'sidebar_reorder', index: idx }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
>
<GripVertical size={16} />
</span>
);
}
function SidebarCustomizer() {
const { t } = useTranslation();
const { items, setItems, toggleItem, reset } = useSidebarStore();
const { isDragging: isPsyDragging } = useDragDrop();
const listRef = useRef<HTMLDivElement>(null);
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
const itemsRef = useRef(items);
itemsRef.current = items;
useEffect(() => {
if (!isPsyDragging) {
dropTargetRef.current = null;
setDropTarget(null);
}
}, [isPsyDragging]);
useEffect(() => {
const el = listRef.current;
if (!el) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'sidebar_reorder' || parsed.index == null) return;
const fromIdx = parsed.index;
const target = dropTargetRef.current;
dropTargetRef.current = null;
setDropTarget(null);
if (target === null) return;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...itemsRef.current];
const [moved] = next.splice(fromIdx, 1);
const adjustedInsert = insertBefore > fromIdx ? insertBefore - 1 : insertBefore;
next.splice(adjustedInsert, 0, moved);
setItems(next);
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [setItems]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !listRef.current) return;
const rows = listRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]');
let target: { idx: number; before: boolean } | null = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.sidebarIdx);
if (e.clientY < rect.top + rect.height / 2) {
target = { idx, before: true };
break;
}
target = { idx, before: false };
}
dropTargetRef.current = target;
setDropTarget(target);
};
return (
<section className="settings-section">
<div className="settings-section-header">
<PanelLeft size={18} />
<h2>{t('settings.sidebarTitle')}</h2>
<button
className="btn btn-ghost"
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
onClick={reset}
data-tooltip={t('settings.sidebarReset')}
>
<RotateCcw size={14} />
</button>
</div>
<div
className="settings-card"
style={{ padding: '4px 0' }}
ref={listRef}
onMouseMove={handleMouseMove}
>
{items.map((cfg, idx) => {
const meta = ALL_NAV_ITEMS[cfg.id];
if (!meta) return null;
const Icon = meta.icon;
const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before;
return (
<div
key={cfg.id}
data-sidebar-idx={idx}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
}}
>
<SidebarGripHandle idx={idx} label={t(meta.labelKey)} />
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
<input type="checkbox" checked={cfg.visible} onChange={() => toggleItem(cfg.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
<div className="sidebar-customizer-fixed-hint">
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
</div>
</div>
</section>
);
}
function ChangelogSection() { function ChangelogSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate); const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
+4
View File
@@ -33,6 +33,7 @@ interface AuthState {
crossfadeEnabled: boolean; crossfadeEnabled: boolean;
crossfadeSecs: number; crossfadeSecs: number;
gaplessEnabled: boolean; gaplessEnabled: boolean;
minimizeToTray: boolean;
showChangelogOnUpdate: boolean; showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string; lastSeenChangelogVersion: string;
@@ -64,6 +65,7 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void; setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void; setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void; setGaplessEnabled: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void; setLastSeenChangelogVersion: (v: string) => void;
logout: () => void; logout: () => void;
@@ -96,6 +98,7 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false, crossfadeEnabled: false,
crossfadeSecs: 3, crossfadeSecs: 3,
gaplessEnabled: false, gaplessEnabled: false,
minimizeToTray: false,
showChangelogOnUpdate: true, showChangelogOnUpdate: true,
lastSeenChangelogVersion: '', lastSeenChangelogVersion: '',
isLoggedIn: false, isLoggedIn: false,
@@ -160,6 +163,7 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }), setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }), setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface SidebarItemConfig {
id: string;
visible: boolean;
}
// All configurable nav items in their default order.
// Fixed items (nowPlaying, settings, offline) are not listed here.
export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'mainstage', visible: true },
{ id: 'newReleases', visible: true },
{ id: 'allAlbums', visible: true },
{ id: 'randomAlbums', visible: true },
{ id: 'artists', visible: true },
{ id: 'genres', visible: true },
{ id: 'randomMix', visible: true },
{ id: 'favorites', visible: true },
{ id: 'playlists', visible: true },
{ id: 'statistics', visible: true },
{ id: 'help', visible: true },
];
interface SidebarStore {
items: SidebarItemConfig[];
setItems: (items: SidebarItemConfig[]) => void;
toggleItem: (id: string) => void;
reset: () => void;
}
export const useSidebarStore = create<SidebarStore>()(
persist(
(set) => ({
items: DEFAULT_SIDEBAR_ITEMS,
setItems: (items) => set({ items }),
toggleItem: (id) => set((s) => ({
items: s.items.map(item => item.id === id ? { ...item, visible: !item.visible } : item),
})),
reset: () => set({ items: DEFAULT_SIDEBAR_ITEMS }),
}),
{ name: 'psysonic_sidebar' }
)
);
+51
View File
@@ -1947,6 +1947,57 @@
gap: var(--space-4); gap: var(--space-4);
} }
.settings-section-divider {
border: none;
border-top: 1px solid var(--border);
margin: var(--space-3) 0;
}
/* ─ Sidebar Customizer ─ */
.sidebar-customizer-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 8px var(--space-4);
border-radius: var(--radius-sm);
transition: background var(--transition-fast), opacity var(--transition-fast);
cursor: default;
}
.sidebar-customizer-row:hover {
background: var(--bg-hover);
}
.sidebar-customizer-row.drag-over {
background: var(--accent-dim);
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.sidebar-customizer-row.dragging {
opacity: 0.4;
}
.sidebar-customizer-grip {
cursor: grab;
color: var(--text-muted);
display: flex;
align-items: center;
flex-shrink: 0;
}
.sidebar-customizer-grip:active {
cursor: grabbing;
}
.sidebar-customizer-fixed-hint {
padding: 8px var(--space-4) 6px;
font-size: 11px;
color: var(--text-muted);
border-top: 1px solid var(--border);
margin-top: 4px;
}
.settings-about { .settings-about {
display: flex; display: flex;
flex-direction: column; flex-direction: column;