mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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
@@ -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,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')
|
||||||
|
|||||||
Generated
+1
-1
@@ -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,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
@@ -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,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
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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" />;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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' : ''}`}
|
||||||
|
|||||||
@@ -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
@@ -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: '关于',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user