Compare commits

..

6 Commits

Author SHA1 Message Date
Psychotoxical d6f6e6466c feat: v1.22.0 — Queue Management, DnD Overhaul, Seek & Waveform Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:14:59 +02:00
Psychotoxical 42863877f6 fix: close seek-flash race window after debounce expires
PR #7 blocked stale Rust progress ticks during the 100 ms debounce
window but lifted the guard exactly when invoke('audio_seek') went out.
The engine can still emit 1–2 ticks with the old position before the
seek takes effect.  Add seekTarget: once the debounce fires, record the
requested time and keep ignoring progress until current_time is within
2 s of that target, then clear it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:28:48 +02:00
Psychotoxical 7ed0fa4914 Merge branch 'pr-8' into test/pr-7-8 2026-03-30 17:24:18 +02:00
Joshua Bassett 4f8e7d7bc7 fix: stabilize waveform seekbar width when player time changes
Add min-width to .player-time elements so they don't resize when the
displayed time string changes length (e.g. "9:59" → "10:00"). Not all
fonts support the tabular-nums font-variant, so without a min-width the
time elements can change size and cause the waveform seekbar to
continuously resize as playback progresses.

Also align the elapsed time to the right and duration to the left so
they sit flush against the waveform edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:17:50 +11:00
Joshua Bassett bb56269cd1 fix: prevent waveform seekbar flashing back to old position on seek
When clicking/dragging the waveform seekbar, the playback position would
briefly flash back to the previous position before snapping to the new
one.

The seek function optimistically updates the store with the target
position immediately but debounces the actual audio_seek IPC call by
100ms. During that window, audio:progress events from the Rust backend
continue to arrive carrying the old playback position, overwriting the
optimistic update and causing the visual flash.

The fix skips incoming audio:progress events while a seek debounce is
pending, since the store already holds the correct target position. Once
the debounce fires and audio_seek is sent to the backend, progress
events resume normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:59:55 +11:00
Psychotoxical 29a4363dca feat: v1.21.0 — What's New Modal, 3 New Themes (Beta), Artist Column, Powerslave Blue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:48:40 +02:00
27 changed files with 1696 additions and 117 deletions
+50
View File
@@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.22.0] - 2026-03-30
### Added
- **Queue — Active Playlist Tracking** *(Beta)* ⚠️: The queue now remembers which playlist was last loaded or saved. The playlist name appears as a subtitle below the queue title. The save button smart-saves: if an active playlist is set, it updates that playlist directly without opening a modal. If no playlist is active, the save modal opens as before.
- **Queue — Themed Delete Confirmation** *(Beta)* ⚠️: Deleting a playlist now shows a styled in-app confirmation dialog matching the current theme, replacing the unstyled native browser `confirm()` dialog.
- **Queue — Load Modal Live Filter** *(Beta)* ⚠️: The playlist load modal now has a live filter input at the top — typing narrows the playlist list in real time.
- **Drag & Drop — Precise Insertion** *(Beta)* ⚠️: Songs and albums dragged into the queue can now be dropped at any position between existing items. A blue insertion line shows exactly where the track will land. Previously all drops appended to the end of the queue.
- **Drag & Drop — Slim Ghost** *(Beta)* ⚠️: The drag ghost is now a compact single-line chip (cover thumbnail + title) instead of the full album card or track row. Consistent for both song and album drags.
### Fixed
- **Seek flash after debounce** *(contributed by [@nullobject](https://github.com/nullobject))*: After a seek the waveform briefly flashed back to the pre-seek position when the Rust `audio:progress` event arrived before the seek completed. A `seekTarget` guard now blocks stale progress ticks until the engine catches up.
- **Waveform seekbar jitter** *(contributed by [@nullobject](https://github.com/nullobject))*: The seekbar width changed on every progress tick because player time updates caused the waveform canvas container to reflow. The canvas now has an explicit stable width so time label changes no longer affect its layout.
- **Drag & Drop — text selection and grid auto-scroll during drag**: Dragging album cards or track rows caused the browser to begin a text selection and auto-scroll grid rows horizontally. All drag `onMouseDown` handlers now call `preventDefault()` and the DragDropContext uses `{ passive: false }` to suppress selection during mouse moves.
- **Drag & Drop — forbidden cursor on KDE Plasma**: Replaced the HTML5 `dragstart`/`dragend` system with a pure mouse-event DnD pipeline (`DragDropContext`). The WebKitGTK forbidden-cursor artefact on KDE Plasma no longer appears during drags.
### Changed
- **Settings — Contributors**: [@nullobject](https://github.com/nullobject) added for seek & waveform fixes.
### Theme Fixes
- **Powerslave**: Connection indicators (Last.fm / Server name) dimmed to match sidebar tone. Back button in album details now white on dark overlay. Tech strip (codec/bitrate) in queue uses dark Nile-blue background instead of sandstone. Artist name in album hero changed to Nile-blue `#050E19`.
- **North Park**: Back button in album details now visible (was dark brown on dark overlay).
- **Dark Side of the Moon**: Album detail year/genre/info brightened from `#555555` to `#888888`. Connection indicators brightened for legibility on near-black sidebar.
---
## [1.21.0] - 2026-03-29
### Added
- **What's New modal**: On first launch after an update, a changelog popup appears showing the current version's release notes. Can be permanently dismissed via checkbox, or re-enabled in Settings → About.
- **New theme category — Famous Albums**: A dedicated group for album-art-inspired themes.
- **Theme — Dark Side of the Moon (inspired)** *(Famous Albums)* ⚠️ **Beta**: Void-black everywhere, the iconic prism spectrum rainbow as a 2 px top border on the player bar, spectrum-violet accent `#9B30FF`, white track name (the input light beam).
- **Theme — Powerslave (inspired)** *(Famous Albums)* ⚠️ **Beta**: Sun-bleached sandstone main area, deep Nile-sky blue sidebar and player bar, pharaoh gold accent `#C8960C`. Bluegold duality mirrors the album artwork's vivid azure sky against the Egyptian temple gold.
- **Theme — North Park** *(Series)* ⚠️ **Beta**: South Park-inspired. Construction-paper cream main area, Colorado mountain-blue `#1B3D6E` sidebar, Kenny orange `#FF8C00` accent, flat no-gradient buttons.
### Changed
- **AlbumTrackList — artist column always visible**: The artist column is now shown on all albums, not only Various Artists compilations. Useful for albums with guest artists or featuring credits where track-level artist differs from the album artist.
- **Tracklist column widths — more flexible**: Title and artist columns now use `minmax` fr units (`1.5fr` / `1fr`) instead of fixed sizes, so the artist column moves naturally closer to the title on wide viewports and never clips on narrow ones.
### Fixed
- **Settings — changelog toggle alignment**: The "Show What's New on update" toggle was rendering below its label instead of beside it.
---
## [1.20.0] - 2026-03-29
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.20.0",
"version": "1.22.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.19.0
pkgver=1.21.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+1 -1
View File
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.19.0"
version = "1.21.0"
dependencies = [
"biquad",
"md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.19.0"
version = "1.21.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.20.0",
"version": "1.22.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+43 -1
View File
@@ -29,6 +29,7 @@ import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import DownloadFolderModal from './components/DownloadFolderModal';
import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
@@ -38,6 +39,8 @@ import OfflineLibrary from './pages/OfflineLibrary';
import Genres from './pages/Genres';
import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal';
import ChangelogModal from './components/ChangelogModal';
import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import { useOfflineStore } from './store/offlineStore';
@@ -111,6 +114,15 @@ function AppShell() {
fn();
}, [currentTrack, isPlaying]);
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
useEffect(() => {
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
setChangelogModalOpen(true);
}
}, []);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
});
@@ -151,6 +163,33 @@ function AppShell() {
};
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
// ── Global DnD fix for Linux/WebKitGTK ──────────────────────────
// WebKitGTK (used by Tauri on Linux) requires the document itself to
// accept drags via preventDefault() on dragover/dragenter. Without
// this, the webview shows a "forbidden" cursor for all in-app HTML5
// drag-and-drop because it never sees a valid drop target at the
// document level. This is harmless on Windows/macOS where DnD already
// works correctly.
useEffect(() => {
const allow = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
// Prevent the webview from navigating when something (e.g. a file
// from the OS file manager) is dropped on the document body.
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
return () => {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
};
}, []);
return (
<div
className="app-shell"
@@ -229,6 +268,7 @@ function AppShell() {
<ContextMenu />
<DownloadFolderModal />
<TooltipPortal />
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
</div>
);
}
@@ -433,7 +473,9 @@ export default function App() {
path="/*"
element={
<RequireAuth>
<AppShell />
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
+5
View File
@@ -392,6 +392,11 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
await api('createPlaylist.view', params);
}
export async function updatePlaylist(id: string, songIds: string[]): Promise<void> {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
}
export async function deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
+16 -8
View File
@@ -7,6 +7,7 @@ import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from './CachedImage';
import { playAlbum } from '../utils/playAlbum';
import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -22,6 +23,7 @@ function AlbumCard({ album }: AlbumCardProps) {
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
return (
<div
@@ -35,14 +37,20 @@ function AlbumCard({ album }: AlbumCardProps) {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
}}
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'album',
id: album.id,
name: album.name,
}));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl: coverUrl || undefined }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="album-card-cover">
+23 -13
View File
@@ -3,6 +3,7 @@ import { Play, Star } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -69,6 +70,7 @@ export default function AlbumTrackList({
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
@@ -93,10 +95,10 @@ export default function AlbumTrackList({
return (
<div className="tracklist">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className={`tracklist-header${' tracklist-va'}`}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
@@ -114,7 +116,7 @@ export default function AlbumTrackList({
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => onPlaySong(song)}
@@ -124,10 +126,20 @@ export default function AlbumTrackList({
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
}}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: makeTrack(song) }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div
@@ -149,11 +161,9 @@ export default function AlbumTrackList({
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
)}
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
@@ -181,7 +191,7 @@ export default function AlbumTrackList({
</div>
))}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className={`tracklist-total${' tracklist-va'}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
+105
View File
@@ -0,0 +1,105 @@
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles } from 'lucide-react';
import { version } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useAuthStore } from '../store/authStore';
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**'))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith('*') && part.endsWith('*'))
return <em key={i}>{part.slice(1, -1)}</em>;
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
return part;
});
}
interface Props {
onClose: () => void;
}
export default function ChangelogModal({ onClose }: Props) {
const { t } = useTranslation();
const [dontShow, setDontShow] = useState(false);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const setLastSeenChangelogVersion = useAuthStore(s => s.setLastSeenChangelogVersion);
const currentVersionData = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
const block = blocks.find((b: string) => b.startsWith(`## [${version}]`));
if (!block) return null;
const lines = block.split('\n');
const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
const body = lines.slice(1).join('\n').trim();
return { version: match?.[1] ?? version, date: match?.[2] ?? '', body };
}, []);
const handleClose = () => {
if (dontShow) setShowChangelogOnUpdate(false);
setLastSeenChangelogVersion(version);
onClose();
};
if (!currentVersionData) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={handleClose} style={{ zIndex: 300 }} />
<div
className="eq-popup"
style={{ zIndex: 301, width: 'min(580px, 92vw)', gap: 0, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
>
<div className="eq-popup-header" style={{ flexShrink: 0 }}>
<span className="eq-popup-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
{t('changelog.modalTitle')} v{version}
</span>
{currentVersionData.date && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{currentVersionData.date}
</span>
)}
</div>
<div style={{ overflowY: 'auto', padding: '12px 20px', flex: 1 }}>
{currentVersionData.body.split('\n').map((line, i) => {
if (line.startsWith('### '))
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
if (line.startsWith('#### '))
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
if (line.startsWith('- '))
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
if (line.trim() === '') return null;
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
})}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderTop: '1px solid var(--border-subtle)',
flexShrink: 0,
gap: '1rem',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" checked={dontShow} onChange={e => setDontShow(e.target.checked)} />
{t('changelog.dontShowAgain')}
</label>
<button className="btn btn-primary" onClick={handleClose}>
{t('changelog.close')}
</button>
</div>
</div>
</>,
document.body
);
}
+218 -27
View File
@@ -1,12 +1,13 @@
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
function formatTime(seconds: number): string {
@@ -59,10 +60,12 @@ function SavePlaylistModal({ onClose, onSave }: { onClose: () => void, onSave: (
);
}
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string) => void }) {
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string, name: string) => void }) {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
const fetchPlaylists = () => {
setLoading(true);
@@ -80,28 +83,44 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
}, []);
const handleDelete = async (id: string, name: string) => {
if (confirm(t('queue.deleteConfirm', { name }))) {
await deletePlaylist(id);
fetchPlaylists();
}
setConfirmDelete({ id, name });
};
const confirmDeletePlaylist = async () => {
if (!confirmDelete) return;
await deletePlaylist(confirmDelete.id);
setConfirmDelete(null);
fetchPlaylists();
};
return (
<>
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<button className="modal-close" onClick={onClose}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
{!loading && playlists.length > 0 && (
<input
type="text"
className="live-search-field"
placeholder={t('queue.filterPlaylists')}
value={filter}
onChange={e => setFilter(e.target.value)}
autoFocus
style={{ width: '100%', marginBottom: '0.75rem', padding: '8px 14px' }}
/>
)}
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>{t('queue.loading')}</p>
) : playlists.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}>{t('queue.noPlaylists')}</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '300px', overflowY: 'auto' }}>
{playlists.map(p => (
{playlists.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())).map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--ctp-surface1)', borderRadius: 'var(--radius-md)' }}>
<span style={{ fontWeight: 500 }} className="truncate" data-tooltip={p.name}>{p.name}</span>
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
<button className="nav-btn" onClick={() => onLoad(p.id)} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
<button className="nav-btn" onClick={() => onLoad(p.id, p.name)} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} data-tooltip={t('queue.delete')} style={{ width: '28px', height: '28px', background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
</div>
</div>
@@ -110,6 +129,25 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
)}
</div>
</div>
{confirmDelete && (
<div className="modal-overlay" onClick={() => setConfirmDelete(null)} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '360px' }}>
<button className="modal-close" onClick={() => setConfirmDelete(null)}><X size={18} /></button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{t('queue.delete')}</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{t('queue.deleteConfirm', { name: confirmDelete.name })}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>{t('queue.cancel')}</button>
<button className="btn btn-primary" style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={confirmDeletePlaylist}>
{t('queue.delete')}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -132,6 +170,7 @@ export default function QueuePanel() {
const reorderQueue = usePlayerStore(s => s.reorderQueue);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const enqueue = usePlayerStore(s => s.enqueue);
const enqueueAt = usePlayerStore(s => s.enqueueAt);
const contextMenu = usePlayerStore(s => s.contextMenu);
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
@@ -171,13 +210,78 @@ export default function QueuePanel() {
const dragOverIdxRef = useRef<number | null>(null);
const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging } = useDragDrop();
useEffect(() => {
if (!isPsyDragging) {
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}, [isPsyDragging]);
const [externalDragOver, setExternalDragOver] = useState(false);
const externalDragCounterRef = useRef(0);
const [externalDropTarget, setExternalDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
const externalDropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
// ── Mouse-event DnD: listen for psy-drop custom events ─────────
useEffect(() => {
const aside = asideRef.current;
if (!aside) return;
const onPsyDrop = async (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsedData: any = null;
try { parsedData = JSON.parse(detail.data); } catch { return; }
const dropTarget = externalDropTargetRef.current;
const insertIdx = dropTarget
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
: usePlayerStore.getState().queue.length;
externalDropTargetRef.current = null;
setExternalDropTarget(null);
if (parsedData.type === 'song') {
enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueueAt(tracks, insertIdx);
}
};
aside.addEventListener('psy-drop', onPsyDrop);
return () => aside.removeEventListener('psy-drop', onPsyDrop);
}, [enqueueAt]);
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const handleSave = () => {
const handleSave = async () => {
if (queue.length === 0) return;
setSaveModalOpen(true);
if (activePlaylist) {
setSaveState('saving');
try {
await updatePlaylist(activePlaylist.id, queue.map(t => t.id));
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 1500);
} catch (e) {
console.error('Failed to update playlist', e);
setSaveState('idle');
}
} else {
setSaveModalOpen(true);
}
};
const handleLoad = () => {
@@ -186,6 +290,7 @@ export default function QueuePanel() {
const handleClear = () => {
clearQueue();
setActivePlaylist(null);
};
const onDragStart = (e: React.DragEvent, index: number) => {
@@ -207,11 +312,20 @@ export default function QueuePanel() {
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
if (!isDraggingInternalRef.current) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
const target = { idx: index, before };
externalDropTargetRef.current = target;
setExternalDropTarget(target);
}
};
const onDragEnd = () => {
setDraggedIdx(null);
setDragOverIdx(null);
setExternalDropTarget(null);
externalDropTargetRef.current = null;
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
@@ -223,12 +337,19 @@ export default function QueuePanel() {
const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault();
// Snapshot drop target before clearing visual state
const droppedTarget = externalDropTargetRef.current;
// Clear visual state immediately
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
externalDragCounterRef.current = 0;
setExternalDragOver(false);
externalDropTargetRef.current = null;
setExternalDropTarget(null);
let parsedData: any = null;
try {
@@ -263,8 +384,13 @@ export default function QueuePanel() {
// External drop (song / album dragged from elsewhere in the app)
_dragFromIdx = null;
if (!parsedData) return;
const insertIdx = droppedTarget
? (droppedTarget.before ? droppedTarget.idx : droppedTarget.idx + 1)
: usePlayerStore.getState().queue.length;
if (parsedData.type === 'song') {
enqueue([parsedData.track]);
enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map(s => ({
@@ -272,21 +398,61 @@ export default function QueuePanel() {
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueue(tracks);
enqueueAt(tracks, insertIdx);
}
};
return (
<aside
className="queue-panel"
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
ref={asideRef}
className={`queue-panel${(externalDragOver || isPsyDragging) ? ' queue-drop-active' : ''}`}
onDragEnter={e => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
if (!isDraggingInternalRef.current) {
externalDragCounterRef.current++;
setExternalDragOver(true);
}
}}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onMouseMove={e => {
if (!isPsyDragging || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false;
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
const before = e.clientY < rect.top + rect.height / 2;
const idx = parseInt(items[i].dataset.queueIdx!);
const target = { idx, before };
externalDropTargetRef.current = target;
setExternalDropTarget(target);
found = true;
break;
}
}
if (!found) {
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}}
onDragLeave={e => {
if (!isDraggingInternalRef.current) {
externalDragCounterRef.current--;
if (externalDragCounterRef.current === 0) {
setExternalDragOver(false);
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}
}}
onDrop={onDropQueue}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
}}
>
<div className="queue-header">
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
@@ -315,6 +481,13 @@ export default function QueuePanel() {
);
})()}
</div>
{activePlaylist && (
<div className="truncate" style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<ListMusic size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{activePlaylist.name}</span>
</div>
)}
</div>
</div>
{currentTrack && (
@@ -362,8 +535,14 @@ export default function QueuePanel() {
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
<button className="queue-round-btn" onClick={handleSave} data-tooltip={t('queue.savePlaylist')} aria-label={t('queue.savePlaylist')}>
<Save size={13} />
<button
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
onClick={handleSave}
disabled={saveState === 'saving'}
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
aria-label={t('queue.savePlaylist')}
>
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
</button>
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
@@ -444,11 +623,19 @@ export default function QueuePanel() {
if (isDragging) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isDragOver && draggedIdx !== null) {
// Internal reorder indicator
if (draggedIdx > idx) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
} else if (externalDropTarget?.idx === idx && draggedIdx === null) {
// External drop insertion indicator
if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
}
return (
@@ -507,23 +694,26 @@ export default function QueuePanel() {
</div>
{saveModalOpen && (
<SavePlaylistModal
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
<SavePlaylistModal
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
try {
await createPlaylist(name, queue.map(t => t.id));
setSaveModalOpen(false);
const playlists = await getPlaylists();
const created = playlists.find(p => p.name === name);
if (created) setActivePlaylist({ id: created.id, name: created.name });
setSaveModalOpen(false);
} catch (e) {
console.error('Failed to save playlist', e);
}
}}
}}
/>
)}
{loadModalOpen && (
<LoadPlaylistModal
onClose={() => setLoadModalOpen(false)}
onLoad={async (id) => {
<LoadPlaylistModal
onClose={() => setLoadModalOpen(false)}
onLoad={async (id, name) => {
try {
const data = await getPlaylist(id);
const tracks: Track[] = data.songs.map(s => ({
@@ -535,11 +725,12 @@ export default function QueuePanel() {
clearQueue();
playTrack(tracks[0], tracks);
}
setLoadModalOpen(false);
setActivePlaylist({ id, name });
setLoadModalOpen(false);
} catch (e) {
console.error('Failed to load playlist', e);
}
}}
}}
/>
)}
</aside>
+8
View File
@@ -105,6 +105,14 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#0b0e12', card: '#141a22', accent: '#35d4f8' },
{ id: 'turtle-power', label: 'Turtle Power', bg: '#1a1a1a', card: '#0a0a0a', accent: '#33cc33' },
{ id: 'north-park', label: 'North Park', bg: '#F5F1E8', card: '#FFFFFF', accent: '#FF8C00' },
],
},
{
group: 'Famous Albums',
themes: [
{ id: 'dark-side-of-the-moon', label: 'Dark Side of the Moon (inspired)', bg: '#050505', card: '#0D0D0D', accent: '#9B30FF' },
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
],
},
{
+233
View File
@@ -0,0 +1,233 @@
/**
* Mouse-event-based Drag & Drop system.
*
* Replaces the HTML5 Drag & Drop API for cross-component drags (song → queue,
* album → queue) because WebKitGTK on Linux always shows a "forbidden" cursor
* during native HTML5 DnD and there is no way to fix it at the GTK level
* without breaking DnD entirely.
*
* This system uses mousedown / mousemove / mouseup which keeps cursor control
* in CSS and avoids the native DnD subsystem completely.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
// ── Types ─────────────────────────────────────────────────────────
export interface DragPayload {
/** Serialised JSON identical to what was previously in dataTransfer */
data: string;
/** Label shown on the ghost element */
label: string;
/** Optional cover URL for the ghost */
coverUrl?: string;
}
interface DragState {
payload: DragPayload | null;
position: { x: number; y: number };
}
interface DragDropContextValue {
/** Begin a drag. Called from mousedown (after threshold). */
startDrag: (payload: DragPayload, x: number, y: number) => void;
/** Current drag payload (null when idle). */
payload: DragPayload | null;
/** Whether a drag is in progress. */
isDragging: boolean;
}
const Ctx = createContext<DragDropContextValue>({
startDrag: () => {},
payload: null,
isDragging: false,
});
export const useDragDrop = () => useContext(Ctx);
// ── Ghost overlay ─────────────────────────────────────────────────
function DragGhost({ state }: { state: DragState }) {
if (!state.payload) return null;
const { label, coverUrl } = state.payload;
return createPortal(
<div
style={{
position: 'fixed',
left: state.position.x + 12,
top: state.position.y - 20,
pointerEvents: 'none',
zIndex: 99999,
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'var(--bg-card, #1e1e2e)',
border: '1px solid var(--border, rgba(255,255,255,0.1))',
borderRadius: 8,
padding: '6px 12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
color: 'var(--text-primary, #fff)',
fontSize: 13,
fontWeight: 500,
maxWidth: 280,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
opacity: 0.95,
userSelect: 'none',
}}
>
{coverUrl && (
<img
src={coverUrl}
alt=""
style={{
width: 28,
height: 28,
borderRadius: 4,
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{label}
</span>
</div>,
document.body,
);
}
// ── Provider ──────────────────────────────────────────────────────
export function DragDropProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<DragState>({
payload: null,
position: { x: 0, y: 0 },
});
const stateRef = useRef(state);
stateRef.current = state;
const startDrag = useCallback(
(payload: DragPayload, x: number, y: number) => {
// Clear any text selection the browser may have started during the
// threshold detection phase (mousedown → mousemove before startDrag).
window.getSelection()?.removeAllRanges();
setState({ payload, position: { x, y } });
},
[],
);
// Global mousemove + mouseup listeners (only while dragging)
useEffect(() => {
if (!state.payload) return;
const onMove = (e: MouseEvent) => {
// preventDefault stops the browser from treating the mouse movement as
// a text-selection drag, which causes element highlighting and
// horizontal auto-scroll in grid containers.
e.preventDefault();
setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
};
const onUp = () => {
// Clear any residual selection (from the pre-threshold phase).
window.getSelection()?.removeAllRanges();
// Dispatch a custom event so drop targets can react.
// The payload is in `detail`.
const evt = new CustomEvent('psy-drop', {
bubbles: true,
detail: stateRef.current.payload,
});
// Find element under cursor
const el = document.elementFromPoint(
stateRef.current.position.x,
stateRef.current.position.y,
);
if (el) el.dispatchEvent(evt);
setState({ payload: null, position: { x: 0, y: 0 } });
};
document.addEventListener('mousemove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
// Add a class so CSS can show grab cursor and suppress selection
document.body.classList.add('psy-dragging');
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.classList.remove('psy-dragging');
};
}, [state.payload !== null]); // eslint-disable-line react-hooks/exhaustive-deps
const ctxValue: DragDropContextValue = {
startDrag,
payload: state.payload,
isDragging: state.payload !== null,
};
return (
<Ctx.Provider value={ctxValue}>
{children}
<DragGhost state={state} />
</Ctx.Provider>
);
}
// ── useDragSource hook ────────────────────────────────────────────
const DRAG_THRESHOLD = 5; // px before drag starts
/**
* Returns an onMouseDown handler for a draggable element.
* Usage: <div {...useDragSource(payload)} />
*/
export function useDragSource(getPayload: () => DragPayload) {
const { startDrag } = useDragDrop();
const startPosRef = useRef<{ x: number; y: number } | null>(null);
const payloadRef = useRef(getPayload);
payloadRef.current = getPayload;
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left-click
if (e.button !== 0) return;
const startX = e.clientX;
const startY = e.clientY;
startPosRef.current = { x: startX, y: startY };
const onMove = (me: MouseEvent) => {
if (!startPosRef.current) return;
const dx = me.clientX - startX;
const dy = me.clientY - startY;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
startPosRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
startDrag(payloadRef.current(), me.clientX, me.clientY);
}
};
const onUp = () => {
startPosRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[startDrag],
);
return { onMouseDown };
}
+65 -30
View File
@@ -350,8 +350,8 @@ const enTranslation = {
clearFolder: 'Clear download folder',
logout: 'Logout',
aboutTitle: 'About Psysonic',
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
aboutFeatures: 'Multi-server support · Scrobbling · Fullscreen player · Album downloads · Image caching · Catppuccin themes',
aboutDesc: 'A modern desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Built on Tauri v2 with a native Rust audio engine — lightweight and fast, yet packed with features: waveform seekbar, synchronized lyrics, Last.fm integration, 10-band EQ, crossfade, gapless playback, Replay Gain, genre browsing, and a large library of themes.',
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Synced lyrics · 10-band EQ · Crossfade & Gapless · Replay Gain · Genres · 63 themes · 5 languages',
aboutLicense: 'License',
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
aboutRepo: 'Source Code on GitHub',
@@ -359,8 +359,10 @@ const enTranslation = {
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
aboutContributorsLabel: 'Contributors',
aboutContributors: 'jiezhuo — Chinese translation',
aboutContributors: 'jiezhuo — Chinese translation · nullobject — Seek & waveform fixes',
changelog: 'Changelog',
showChangelogOnUpdate: "Show 'What's New' on update",
showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.",
randomMixTitle: 'Random Mix',
randomMixBlacklistTitle: 'Custom Filter Keywords',
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
@@ -405,6 +407,11 @@ const enTranslation = {
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
experimental: 'Experimental',
},
changelog: {
modalTitle: "What's New",
dontShowAgain: "Don't show again",
close: 'Got it',
},
help: {
title: 'Help',
s1: 'Getting Started',
@@ -434,7 +441,7 @@ const enTranslation = {
a11: 'The banner at the top of the home page randomly picks albums from your library and rotates through them every 10 seconds. Click the dots to jump to a specific one, or click the banner to open the album.',
s4: 'Settings',
q12: 'How do I change the theme?',
a12: 'Settings → Theme. Choose from 47 themes across 7 groups: Psysonic Themes, Psysonic Mediaplayer, Operating Systems, Games, Movies, Series, and Open Source Classics (Catppuccin, Nord, Gruvbox).',
a12: 'Settings → Theme. Choose from a large selection of themes across 8 groups: Psysonic Themes, Mediaplayer, Operating Systems, Games, Movies, Series, Social Media, and Open Source Classics (Catppuccin, Nord, Gruvbox).',
q13: 'How do I change the language?',
a13: 'Settings → Language. English, German, French, Dutch, and Chinese are supported.',
q15: 'How do I set a download folder?',
@@ -470,8 +477,6 @@ const enTranslation = {
a29: 'Both are experimental audio features in Settings → Audio. Gapless pre-buffers the next track to eliminate silence between songs. Crossfade fades out the current track while fading in the next — adjust the duration (110 s) to taste.',
q30: 'Does Psysonic show lyrics?',
a30: 'Yes. Click the microphone icon in the player bar to open the Lyrics tab in the queue panel. Psysonic fetches lyrics automatically from LRCLIB. If synced lyrics are available, the active line is highlighted and scrolls in real time as the song plays. If only plain text is available, the full text is shown statically.',
q33: 'Can I seek (scrub) within a FLAC file?',
a33: 'Seeking in FLAC files requires a SEEKTABLE metadata block to be embedded in the file. Files that were encoded without one cannot be seeked — clicking the waveform will have no effect. Most modern encoders (e.g. ffmpeg, flac CLI) include a SEEKTABLE by default. You can add one to existing files using: flac --replay-gain *.flac or metaflac --add-seekpoint=10s *.flac',
q31: 'Can I customize keyboard shortcuts?',
a31: 'Yes. Settings → Keyboard Shortcuts lets you remap in-app actions (Play/Pause, Next, Previous, Volume Up/Down, Fullscreen, and more) to any key. Settings → Global Shortcuts lets you assign system-wide shortcuts that trigger even when Psysonic is in the background.',
q32: 'Can I change the font?',
@@ -487,6 +492,8 @@ const enTranslation = {
queue: {
title: 'Queue',
savePlaylist: 'Save Playlist',
updatePlaylist: 'Update Playlist',
filterPlaylists: 'Filter playlists…',
playlistName: 'Playlist Name',
cancel: 'Cancel',
save: 'Save',
@@ -920,8 +927,8 @@ const deTranslation = {
clearFolder: 'Download-Ordner löschen',
logout: 'Abmelden',
aboutTitle: 'Über Psysonic',
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
aboutDesc: 'Ein moderner Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Basiert auf Tauri v2 mit einer nativen Rust-Audio-Engine — schlank und schnell, aber vollgepackt mit Features: Wellenform-Seekbar, synchronisierte Lyrics, Last.fm-Integration, 10-Band-EQ, Crossfade, nahtlose Wiedergabe, Replay Gain, Genre-Browser und eine große Theme-Bibliothek.',
aboutFeatures: 'Multi-Server · Last.fm Scrobbling & Love · Vollbild Ambient Stage · Wellenform-Seek · Sync-Lyrics · 10-Band-EQ · Crossfade & Gapless · Replay Gain · Genres · 63 Themes · 5 Sprachen',
aboutLicense: 'Lizenz',
aboutLicenseText: 'GNU GPL v3 — kostenlos nutzbar, veränderbar und unter gleicher Lizenz weiterzugeben.',
aboutRepo: 'Quellcode auf GitHub',
@@ -929,8 +936,10 @@ const deTranslation = {
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
aboutContributorsLabel: 'Mitwirkende',
aboutContributors: 'jiezhuo — Chinesische Übersetzung',
aboutContributors: 'jiezhuo — Chinesische Übersetzung · nullobject — Seek & Waveform-Fixes',
changelog: 'Changelog',
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.',
randomMixTitle: 'Zufallsmix',
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
@@ -975,6 +984,11 @@ const deTranslation = {
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
experimental: 'Experimentell',
},
changelog: {
modalTitle: 'Was ist neu',
dontShowAgain: 'Nicht mehr anzeigen',
close: 'Verstanden',
},
help: {
title: 'Hilfe',
s1: 'Erste Schritte',
@@ -1004,7 +1018,7 @@ const deTranslation = {
a11: 'Das Banner oben auf der Startseite wählt zufällige Alben aus der Bibliothek und rotiert alle 10 Sekunden weiter. Mit den Punkten kann man manuell springen, Klick auf das Banner öffnet das Album.',
s4: 'Einstellungen',
q12: 'Wie ändere ich das Theme?',
a12: 'Einstellungen → Design. 47 Designs in 7 Gruppen: Psysonic Themes, Psysonic Mediaplayer, Betriebssysteme, Spiele, Filme, Serien und Open-Source-Classics (Catppuccin, Nord, Gruvbox).',
a12: 'Einstellungen → Design. Eine große Auswahl an Themes in 8 Gruppen: Psysonic Themes, Mediaplayer, Betriebssysteme, Spiele, Filme, Serien, Social Media und Open-Source-Classics (Catppuccin, Nord, Gruvbox).',
q13: 'Wie ändere ich die Sprache?',
a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch, Niederländisch und Chinesisch werden unterstützt.',
q15: 'Wie lege ich einen Download-Ordner fest?',
@@ -1040,8 +1054,6 @@ const deTranslation = {
a29: 'Beides sind experimentelle Audiofunktionen in Einstellungen → Audio. Nahtlose Wiedergabe puffert den nächsten Track vor, um Stille zu eliminieren. Crossfade blendet den aktuellen Track aus, während der nächste eingeblendet wird — Dauer (110 s) einstellbar.',
q30: 'Zeigt Psysonic Songtexte an?',
a30: 'Ja. Klick auf das Mikrofon-Icon in der Playerleiste öffnet den Lyrics-Tab im Queue-Panel. Psysonic lädt Texte automatisch von LRCLIB. Sind synchronisierte Lyrics verfügbar, wird die aktuelle Zeile hervorgehoben und scrollt in Echtzeit mit. Bei reinen Texten wird der Volltext statisch angezeigt.',
q33: 'Kann ich in FLAC-Dateien seekbar (spulen)?',
a33: 'Das Spulen in FLAC-Dateien erfordert einen SEEKTABLE-Metadatenblock in der Datei. Dateien, die ohne diesen Block kodiert wurden, können nicht geseekt werden — ein Klick auf die Wellenform hat keinen Effekt. Die meisten modernen Encoder (z. B. ffmpeg, flac CLI) fügen standardmäßig eine SEEKTABLE ein. Mit folgendem Befehl kann man sie nachträglich hinzufügen: flac --replay-gain *.flac oder metaflac --add-seekpoint=10s *.flac',
q31: 'Kann ich Tastenkürzel anpassen?',
a31: 'Ja. Einstellungen → Tastenkürzel erlaubt das Neuzuordnen von In-App-Aktionen (Play/Pause, Weiter, Zurück, Lautstärke, Vollbild u. v. m.). Einstellungen → Globale Shortcuts ermöglicht systemweite Kürzel, die auch im Hintergrund ausgelöst werden.',
q32: 'Kann ich die Schriftart ändern?',
@@ -1057,6 +1069,8 @@ const deTranslation = {
queue: {
title: 'Warteschlange',
savePlaylist: 'Playlist speichern',
updatePlaylist: 'Playlist aktualisieren',
filterPlaylists: 'Playlists filtern…',
playlistName: 'Name der Playlist',
cancel: 'Abbrechen',
save: 'Speichern',
@@ -1490,8 +1504,8 @@ const frTranslation = {
clearFolder: 'Effacer le dossier',
logout: 'Se déconnecter',
aboutTitle: 'À propos de Psysonic',
aboutDesc: 'Un lecteur de musique de bureau pour les serveurs compatibles Subsonic (Navidrome, Gonic, et autres). Diffuse votre bibliothèque musicale auto-hébergée avec une interface moderne et épurée.',
aboutFeatures: 'Multi-serveur · Scrobbling · Lecteur plein écran · Téléchargement d\'albums · Cache d\'images · Thèmes Catppuccin',
aboutDesc: 'Un lecteur de musique de bureau moderne pour les serveurs compatibles Subsonic (Navidrome, Gonic, et autres). Construit sur Tauri v2 avec un moteur audio Rust natif — léger et rapide, mais riche en fonctionnalités : barre waveform, paroles synchronisées, intégration Last.fm, égaliseur 10 bandes, crossfade, lecture sans blanc, Replay Gain, navigation par genre et une grande bibliothèque de thèmes.',
aboutFeatures: 'Multi-serveur · Scrobbling & love Last.fm · Ambient Stage plein écran · Waveform seek · Paroles synchro · EQ 10 bandes · Crossfade & Gapless · Replay Gain · Genres · 63 thèmes · 5 langues',
aboutLicense: 'Licence',
aboutLicenseText: 'GNU GPL v3 — libre d\'utilisation, de modification et de distribution sous la même licence.',
aboutRepo: 'Code source sur GitHub',
@@ -1499,8 +1513,10 @@ const frTranslation = {
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
aboutContributorsLabel: 'Contributeurs',
aboutContributors: 'jiezhuo — traduction chinoise',
aboutContributors: 'jiezhuo — traduction chinoise · nullobject — correctifs seek & waveform',
changelog: 'Journal des modifications',
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.',
randomMixTitle: 'Mix aléatoire',
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
@@ -1545,6 +1561,11 @@ const frTranslation = {
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
experimental: 'Expérimental',
},
changelog: {
modalTitle: 'Quoi de neuf',
dontShowAgain: 'Ne plus afficher',
close: 'Compris',
},
help: {
title: 'Aide',
s1: 'Démarrage',
@@ -1574,7 +1595,7 @@ const frTranslation = {
a11: 'La bannière en haut de la page d\'accueil sélectionne aléatoirement des albums de votre bibliothèque et les fait défiler toutes les 10 secondes. Cliquez sur les points pour accéder à un album spécifique.',
s4: 'Paramètres',
q12: 'Comment changer le thème ?',
a12: 'Paramètres → Thème. 47 thèmes en 7 groupes : Psysonic Themes, Psysonic Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries et Open Source Classics (Catppuccin, Nord, Gruvbox).',
a12: 'Paramètres → Thème. Un grand choix de thèmes en 8 groupes : Psysonic Themes, Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries, Réseaux sociaux et Open Source Classics (Catppuccin, Nord, Gruvbox).',
q13: 'Comment changer la langue ?',
a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français, le néerlandais et le chinois sont pris en charge.',
q15: 'Comment définir un dossier de téléchargement ?',
@@ -1610,8 +1631,6 @@ const frTranslation = {
a29: 'Ce sont des fonctionnalités audio expérimentales dans Paramètres → Audio. La lecture sans blanc pré-charge la piste suivante pour éliminer les silences. Le fondu enchaîné effectue un fondu sortant de la piste en cours tout en faisant un fondu entrant de la suivante — durée réglable (110 s).',
q30: 'Psysonic affiche-t-il les paroles ?',
a30: 'Oui. Cliquez sur l\'icône microphone dans la barre du lecteur pour ouvrir l\'onglet Paroles. Psysonic récupère automatiquement les paroles depuis LRCLIB. Si des paroles synchronisées sont disponibles, la ligne active est mise en évidence et défile en temps réel. Sinon, le texte complet est affiché statiquement.',
q33: 'Puis-je faire une recherche (scrubbing) dans un fichier FLAC ?',
a33: 'La navigation dans les fichiers FLAC nécessite un bloc de métadonnées SEEKTABLE intégré dans le fichier. Les fichiers encodés sans ce bloc ne peuvent pas être recherchés — cliquer sur la forme d\'onde n\'aura aucun effet. La plupart des encodeurs modernes (p. ex. ffmpeg, flac CLI) incluent une SEEKTABLE par défaut. Vous pouvez en ajouter une aux fichiers existants avec : flac --replay-gain *.flac ou metaflac --add-seekpoint=10s *.flac',
q31: 'Puis-je personnaliser les raccourcis clavier ?',
a31: 'Oui. Paramètres → Raccourcis clavier permet de réattribuer les actions in-app (Lecture/Pause, Suivant, Précédent, Volume, Plein écran, etc.). Paramètres → Raccourcis globaux permet d\'assigner des raccourcis système actifs même quand l\'application est en arrière-plan.',
q32: 'Puis-je changer la police ?',
@@ -1627,6 +1646,8 @@ const frTranslation = {
queue: {
title: 'File d\'attente',
savePlaylist: 'Enregistrer la liste',
updatePlaylist: 'Mettre à jour la liste',
filterPlaylists: 'Filtrer les listes…',
playlistName: 'Nom de la liste',
cancel: 'Annuler',
save: 'Enregistrer',
@@ -2060,8 +2081,8 @@ const nlTranslation = {
clearFolder: 'Map wissen',
logout: 'Uitloggen',
aboutTitle: 'Over Psysonic',
aboutDesc: 'Een desktopmuziekspeeler voor Subsonic-compatibele servers (Navidrome, Gonic en anderen). Streamt je zelf gehoste muziekbibliotheek met een strakke, moderne interface.',
aboutFeatures: 'Multi-server · Scrobbling · Volledig scherm · Album downloads · Afbeeldingscache · Catppuccin-thema\'s',
aboutDesc: 'Een moderne desktopmuziekspeeler voor Subsonic-compatibele servers (Navidrome, Gonic en anderen). Gebouwd op Tauri v2 met een native Rust audio-engine — licht en snel, maar boordevol functies: golfvorm-zoekbalk, gesynchroniseerde songteksten, Last.fm-integratie, 10-bands equalizer, crossfade, naadloos afspelen, Replay Gain, genres en een uitgebreide themabibliotheek.',
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Syncteksten · 10-bands EQ · Crossfade & Gapless · Replay Gain · Genres · 63 thema\'s · 5 talen',
aboutLicense: 'Licentie',
aboutLicenseText: 'GNU GPL v3 — vrij te gebruiken, wijzigen en verspreiden onder dezelfde licentie.',
aboutRepo: 'Broncode op GitHub',
@@ -2069,8 +2090,10 @@ const nlTranslation = {
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
aboutContributorsLabel: 'Bijdragers',
aboutContributors: 'jiezhuo — Chinese vertaling',
aboutContributors: 'jiezhuo — Chinese vertaling · nullobject — seek & waveform-fixes',
changelog: 'Wijzigingslog',
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.',
randomMixTitle: 'Willekeurige mix',
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
@@ -2115,6 +2138,11 @@ const nlTranslation = {
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
experimental: 'Experimenteel',
},
changelog: {
modalTitle: 'Wat is nieuw',
dontShowAgain: 'Niet meer weergeven',
close: 'Begrepen',
},
help: {
title: 'Help',
s1: 'Aan de slag',
@@ -2144,7 +2172,7 @@ const nlTranslation = {
a11: 'De banner bovenaan de startpagina kiest willekeurig albums uit je bibliotheek en wisselt deze elke 10 seconden af. Klik op de puntjes om naar een specifiek album te springen.',
s4: 'Instellingen',
q12: 'Hoe verander ik het thema?',
a12: 'Instellingen → Thema. 47 thema\'s in 7 groepen: Psysonic Themes, Psysonic Mediaplayer, Besturingssystemen, Games, Films, Series en Open Source Classics (Catppuccin, Nord, Gruvbox).',
a12: 'Instellingen → Thema. Een ruime keuze aan thema\'s in 8 groepen: Psysonic Themes, Mediaplayer, Besturingssystemen, Games, Films, Series, Social Media en Open Source Classics (Catppuccin, Nord, Gruvbox).',
q13: 'Hoe verander ik de taal?',
a13: 'Instellingen → Taal. Engels, Duits, Frans, Nederlands en Chinees worden momenteel ondersteund.',
q15: 'Hoe stel ik een downloadmap in?',
@@ -2180,8 +2208,6 @@ const nlTranslation = {
a29: 'Dit zijn experimentele audiofuncties in Instellingen → Audio. Naadloos afspelen buffert het volgende nummer voor om stiltes te elimineren. Overgang fadeert het huidige nummer uit terwijl het volgende infadeert — duur (110 s) instelbaar.',
q30: 'Toont Psysonic songteksten?',
a30: 'Ja. Klik op het microfoonpictogram in de spelerbalk om het tabblad Songteksten te openen. Psysonic haalt teksten automatisch op van LRCLIB. Als gesynchroniseerde teksten beschikbaar zijn, wordt de actieve regel gemarkeerd en scrolt mee in realtime. Anders wordt de volledige tekst statisch getoond.',
q33: 'Kan ik door een FLAC-bestand scrubben (zoeken)?',
a33: 'Zoeken in FLAC-bestanden vereist een SEEKTABLE-metadatablok in het bestand. Bestanden die zonder dit blok zijn gecodeerd, kunnen niet worden doorzocht — klikken op de golfvorm heeft geen effect. De meeste moderne encoders (bijv. ffmpeg, flac CLI) bevatten standaard een SEEKTABLE. U kunt er een toevoegen aan bestaande bestanden met: flac --replay-gain *.flac of metaflac --add-seekpoint=10s *.flac',
q31: 'Kan ik sneltoetsen aanpassen?',
a31: 'Ja. Instellingen → Sneltoetsen laat je in-app acties (Afspelen/Pauzeren, Volgende, Vorige, Volume, Volledig scherm, enz.) hertoewijzen. Instellingen → Globale sneltoetsen laat je systeembrede sneltoetsen instellen die werken ook als de app op de achtergrond staat.',
q32: 'Kan ik het lettertype wijzigen?',
@@ -2197,6 +2223,8 @@ const nlTranslation = {
queue: {
title: 'Wachtrij',
savePlaylist: 'Afspeellijst opslaan',
updatePlaylist: 'Afspeellijst bijwerken',
filterPlaylists: 'Afspeellijsten filteren…',
playlistName: 'Naam afspeellijst',
cancel: 'Annuleren',
save: 'Opslaan',
@@ -2630,8 +2658,8 @@ const zhTranslation = {
clearFolder: '清除下载文件夹',
logout: '退出登录',
aboutTitle: '关于 Psysonic',
aboutDesc: '适用于 Subsonic 兼容服务器(Navidrome、Gonic 等)的桌面音乐播放器。以简洁现代的界面流媒体播放您的自托管音乐库,采用 Catppuccin 配色方案风格。',
aboutFeatures: '多服务器支持 · 歌曲记录 · 全屏播放 · 专辑下载 · 图片缓存 · Catppuccin 主题',
aboutDesc: '适用于 Subsonic 兼容服务器(Navidrome、Gonic 等)的现代桌面音乐播放器。基于 Tauri v2 和原生 Rust 音频引擎构建——轻量快速,功能丰富:波形进度条、同步歌词、Last.fm 集成、10 段均衡器、交叉淡入淡出、无缝播放、响度增益、流派浏览以及大量精美主题。',
aboutFeatures: '多服务器 · Last.fm 记录 & 喜爱 · 全屏环境舞台 · 波形跳转 · 同步歌词 · 10 段 EQ · 交叉淡入淡出 & 无缝播放 · 响度增益 · 流派 · 63 个主题 · 5 种语言',
aboutLicense: '许可证',
aboutLicenseText: 'GNU GPL v3 — 在相同许可证下可自由使用、修改和分发。',
aboutRepo: 'GitHub 上的源代码',
@@ -2639,8 +2667,10 @@ const zhTranslation = {
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
aboutContributorsLabel: '贡献者',
aboutContributors: 'jiezhuo — 中文翻译',
aboutContributors: 'jiezhuo — 中文翻译 · nullobject — Seek & 波形修复',
changelog: '更新日志',
showChangelogOnUpdate: '更新时显示"新功能"',
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
randomMixTitle: '随机混音',
randomMixBlacklistTitle: '自定义过滤关键词',
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
@@ -2685,6 +2715,11 @@ const zhTranslation = {
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
experimental: '实验性',
},
changelog: {
modalTitle: '新功能',
dontShowAgain: '不再显示',
close: '知道了',
},
help: {
title: '帮助',
s1: '入门指南',
@@ -2714,7 +2749,7 @@ const zhTranslation = {
a11: '首页顶部的横幅会随机从您的音乐库中选择专辑,每 10 秒轮换一次。点击圆点可跳转到特定专辑,或点击横幅打开专辑。',
s4: '设置',
q12: '如何更改主题?',
a12: '设置 → 主题。从 7 个分组共 47 个主题中选择:Psysonic 主题、Psysonic 媒体播放器、操作系统、游戏、电影、电视剧,以及开源经典(Catppuccin、Nord、Gruvbox)。',
a12: '设置 → 主题。从 8 个分组中的大量主题中选择:Psysonic 主题、媒体播放器、操作系统、游戏、电影、电视剧、社交媒体,以及开源经典(Catppuccin、Nord、Gruvbox)。',
q13: '如何更改语言?',
a13: '设置 → 语言。支持英语、德语、法语、荷兰语和中文。',
q15: '如何设置下载文件夹?',
@@ -2750,8 +2785,6 @@ const zhTranslation = {
a29: '两者都是 设置 → 音频 中的实验性音频功能。无缝播放预缓冲下一首曲目以消除歌曲间的静音。交叉淡入淡出让当前曲目淡出同时下一首淡入 — 调整持续时间(1-10 秒)以符合您的喜好。',
q30: 'Psysonic 显示歌词吗?',
a30: '是的。点击播放器栏的麦克风图标可在队列面板中打开歌词标签。Psysonic 自动从 LRCLIB 获取歌词。如果可用同步歌词,活动行会高亮并随歌曲实时滚动。如果只有纯文本歌词,则静态显示完整文本。',
q33: '可以在 FLAC 文件中拖动(拖动播放)吗?',
a33: '在 FLAC 文件中拖动需要文件中嵌入 SEEKTABLE 元数据块。未编码此块的文件无法拖动 — 点击波形将无效。大多数现代编码器(如 ffmpeg、flac CLI)默认包含 SEEKTABLE。您可以使用以下命令为现有文件添加:flac --replay-gain *.flac 或 metaflac --add-seekpoint=10s *.flac',
q31: '可以自定义键盘快捷键吗?',
a31: '可以。设置 → 键盘快捷键 允许您重新映射应用内操作(播放/暂停、下一首、上一首、音量增/减、全屏等)到任意按键。设置 → 全局快捷键 允许您分配即使 Psysonic 在后台也能触发的系统级快捷键。',
q32: '可以更改字体吗?',
@@ -2767,6 +2800,8 @@ const zhTranslation = {
queue: {
title: '队列',
savePlaylist: '保存为播放列表',
updatePlaylist: '更新播放列表',
filterPlaylists: '筛选播放列表…',
playlistName: '播放列表名称',
cancel: '取消',
save: '保存',
+17 -4
View File
@@ -7,6 +7,7 @@ import { ListPlus, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext';
export default function Favorites() {
const { t } = useTranslation();
@@ -16,6 +17,7 @@ export default function Favorites() {
const [loading, setLoading] = useState(true);
const { playTrack, enqueue } = usePlayerStore();
const psyDrag = useDragDrop();
function removeSong(id: string) {
unstar(id, 'song').catch(() => {});
@@ -104,10 +106,21 @@ export default function Favorites() {
onDoubleClick={() => playTrack(song, songs)}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
draggable={false}
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="track-num col-center" onClick={() => playTrack(song, songs)} style={{ cursor: 'pointer' }}>
-1
View File
@@ -46,7 +46,6 @@ export default function Help() {
{ q: t('help.q24'), a: t('help.a24') },
{ q: t('help.q29'), a: t('help.a29') },
{ q: t('help.q30'), a: t('help.a30') },
{ q: t('help.q33'), a: t('help.a33') },
],
},
{
+31 -12
View File
@@ -4,6 +4,7 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
const AUDIOBOOK_GENRES = [
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
@@ -45,6 +46,7 @@ export default function RandomMix() {
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState<string | null>(null);
@@ -343,11 +345,22 @@ export default function RandomMix() {
</div>
{genreMixSongs.map(song => (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row"
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre }, 'song'); }}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre } }));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre } }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
@@ -390,21 +403,27 @@ export default function RandomMix() {
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => playTrack(song, filteredSongs)}
role="row"
draggable
onContextMenu={e => {
e.preventDefault();
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre };
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre,
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre };
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button
+17 -9
View File
@@ -6,6 +6,7 @@ import { usePlayerStore } from '../store/playerStore';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
@@ -19,6 +20,7 @@ export default function SearchResults() {
const [loading, setLoading] = useState(false);
const playTrack = usePlayerStore(s => s.playTrack);
const currentTrack = usePlayerStore(s => s.currentTrack);
const psyDrag = useDragDrop();
useEffect(() => {
if (!query.trim()) { setResults(null); return; }
@@ -92,16 +94,22 @@ export default function SearchResults() {
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
onDoubleClick={() => playSong(song, results.songs)}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating, genre: song.genre,
draggable={false}
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre };
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button
+12
View File
@@ -1065,6 +1065,8 @@ function renderInline(text: string): React.ReactNode[] {
function ChangelogSection() {
const { t } = useTranslation();
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const versions = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## ['));
@@ -1088,6 +1090,16 @@ function ChangelogSection() {
<Info size={18} />
<h2>{t('settings.changelog')}</h2>
</div>
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showChangelogOnUpdate')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showChangelogOnUpdateDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showChangelogOnUpdate')}>
<input type="checkbox" checked={showChangelogOnUpdate} onChange={e => setShowChangelogOnUpdate(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="changelog-list">
{versions.map(({ version, date, body }) => (
<details key={version} className="changelog-entry" open={version === appVersion}>
+8
View File
@@ -31,6 +31,8 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
// Status
isLoggedIn: boolean;
@@ -60,6 +62,8 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
logout: () => void;
// Derived
@@ -90,6 +94,8 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -146,6 +152,8 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
logout: () => set({ isLoggedIn: false }),
+36 -3
View File
@@ -77,6 +77,7 @@ interface PlayerState {
setVolume: (v: number) => void;
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void;
enqueueAt: (tracks: Track[], insertIndex: number) => void;
clearQueue: () => void;
isQueueVisible: boolean;
@@ -125,6 +126,9 @@ let playGeneration = 0;
// Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Target time of the last seek — blocks stale Rust progress ticks until the
// engine has actually caught up to the new position.
let seekTarget: number | null = null;
// Guard against rapid double-click play/pause sending two state transitions
// to the Rust backend before it has finished the previous one.
@@ -159,6 +163,18 @@ function handleAudioPlaying(_duration: number) {
}
function handleAudioProgress(current_time: number, duration: number) {
// While a seek is pending, the store already holds the optimistic target
// position. Accepting stale progress from the Rust engine would briefly
// snap the waveform back to the old position before the seek completes.
if (seekDebounce) return;
// After the debounce fires, Rust may still emit 12 ticks with the old
// position before the seek takes effect. Block until current_time is
// within 2 s of the requested target, then clear the guard.
if (seekTarget !== null) {
if (Math.abs(current_time - seekTarget) > 2.0) return;
seekTarget = null;
}
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track) return;
@@ -484,7 +500,7 @@ export const usePlayerStore = create<PlayerState>()(
stop: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
},
@@ -499,7 +515,7 @@ export const usePlayerStore = create<PlayerState>()(
const gen = ++playGeneration;
isAudioPaused = false;
gaplessPreloadingId = null; // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
const state = get();
const newQueue = queue ?? state.queue;
@@ -650,6 +666,7 @@ export const usePlayerStore = create<PlayerState>()(
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
seekTarget = time;
invoke('audio_seek', { seconds: time }).catch(console.error);
}, 100);
},
@@ -674,10 +691,26 @@ export const usePlayerStore = create<PlayerState>()(
});
},
enqueueAt: (tracks, insertIndex) => {
set(state => {
const idx = Math.max(0, Math.min(insertIndex, state.queue.length));
const newQueue = [
...state.queue.slice(0, idx),
...tracks,
...state.queue.slice(idx),
];
const newQueueIndex = idx <= state.queueIndex
? state.queueIndex + tracks.length
: state.queueIndex;
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue, queueIndex: newQueueIndex };
});
},
clearQueue: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
syncQueueToServer([], null, 0);
},
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave';
interface ThemeState {
theme: Theme;
+3 -3
View File
@@ -1036,7 +1036,7 @@
}
.tracklist-header.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.track-row {
@@ -1052,7 +1052,7 @@
}
.track-row.track-row-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.tracklist-total {
@@ -1065,7 +1065,7 @@
}
.tracklist-total.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.tracklist-total-label {
+14
View File
@@ -632,6 +632,15 @@
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
min-width: 2.5rem;
}
.player-time:first-child {
text-align: right;
}
.player-time:last-child {
text-align: left;
}
/* Volume section */
@@ -750,6 +759,11 @@
min-height: 0; /* allow 1fr row to shrink freely */
}
.queue-panel.queue-drop-active {
box-shadow: inset 0 0 0 2px var(--accent);
background: linear-gradient(var(--accent-dim), var(--accent-dim)), var(--bg-sidebar);
}
.queue-header {
height: 64px;
box-sizing: border-box;
+710
View File
@@ -3196,6 +3196,16 @@ body.is-dragging * {
cursor: col-resize !important;
}
/* Mouse-event DnD cursor (psy-dragging)
Applied by DragDropProvider while a mouse-based drag is active.
Overrides cursor globally to "grabbing" and prevents text-
selection / stray pointer events on child elements. */
body.psy-dragging,
body.psy-dragging * {
cursor: grabbing !important;
user-select: none !important;
}
/* ─── Utility Classes ─── */
.glass {
background: var(--bg-glass, rgba(30, 30, 46, 0.75));
@@ -10987,3 +10997,703 @@ input[type="range"]:hover::-webkit-slider-thumb {
/* Connection indicators */
[data-theme='toy-tale'] .connection-type,
[data-theme='toy-tale'] .connection-server { color: #6a5030; }
/* ─── North Park — South Park ─── */
/* Construction paper flat aesthetic paper-cream main, Colorado mountain-blue sidebar,
Kenny orange accent, South Park sky blue player accents. Light, no dark mode. */
[data-theme='north-park'] {
color-scheme: light;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23FF8C00%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* ── Palette — Kenny orange, South Park sky, Colorado Rockies ── */
--ctp-crust: #0D2040;
--ctp-mantle: #142D50;
--ctp-base: #F5F1E8; /* construction paper cream */
--ctp-surface0: #EDE9DE;
--ctp-surface1: #E5E0D2;
--ctp-surface2: #D8D2C0;
--ctp-overlay0: #8090A8;
--ctp-overlay1: #5A6A80;
--ctp-overlay2: #4A526A;
--ctp-text: #1A1A2E;
--ctp-subtext1: #3A3A5A;
--ctp-subtext0: #4A526A;
--ctp-mauve: #FF8C00; /* Kenny orange */
--ctp-lavender: #FFB030;
--ctp-pink: #FF6A00;
--ctp-flamingo: #FF5500;
--ctp-rosewater:#FFD080;
--ctp-green: #3D9A2A; /* Kyle's green hat */
--ctp-teal: #2080A0;
--ctp-sky: #87CEEB; /* South Park sky */
--ctp-blue: #1E4080;
--ctp-sapphire: #1B3D6E;
--ctp-yellow: #FFD700;
--ctp-peach: #FF8C00;
--ctp-maroon: #CC4400;
--ctp-red: #CC2200; /* Cartman red */
--bg-app: #F5F1E8; /* construction paper cream */
--bg-sidebar: #1B3D6E; /* Colorado Rockies night blue */
--bg-card: #FFFFFF;
--bg-hover: #EDE9DE;
--bg-player: #102A50; /* deeper mountain blue */
--bg-glass: rgba(245, 241, 232, 0.94);
--accent: #FF8C00; /* Kenny orange */
--accent-dim: rgba(255, 140, 0, 0.13);
--accent-glow: rgba(255, 140, 0, 0.30);
--text-primary: #1A1A2E;
--text-secondary:#4A526A;
--text-muted: #8090A8;
--border: rgba(27, 61, 110, 0.14);
--border-subtle: rgba(27, 61, 110, 0.08);
--border-dropdown: rgba(255, 140, 0, 0.28);
--shadow-dropdown: rgba(0, 20, 60, 0.18);
--positive: #3D9A2A;
--warning: #E8A000;
--danger: #CC2200;
--radius-sm: 3px;
--radius-md: 6px;
--radius-lg: 10px;
/* Logo: Kenny orange → South Park yellow */
--logo-color-start: #FF8C00;
--logo-color-end: #FFD700;
}
/* Sidebar — Colorado mountain blue, orange nav accents */
[data-theme='north-park'] .sidebar {
background: #1B3D6E;
border-right: none;
}
/* Nav links: default text light on dark sidebar */
[data-theme='north-park'] .nav-link { color: rgba(255, 255, 255, 0.72); }
[data-theme='north-park'] .nav-link.active {
background: rgba(255, 140, 0, 0.18);
border-left: 3px solid #FF8C00;
color: #FF8C00;
}
[data-theme='north-park'] .nav-link:hover:not(.active) {
background: rgba(255, 255, 255, 0.09);
border-left: 3px solid rgba(255, 140, 0, 0.45);
color: rgba(255, 255, 255, 0.95);
}
/* Player bar — deep mountain blue, Kenny orange top stripe */
[data-theme='north-park'] .player-bar {
background: #102A50;
border-top: 3px solid #FF8C00;
box-shadow: 0 -2px 20px rgba(0, 20, 60, 0.30);
}
/* Track name — Kenny orange */
[data-theme='north-park'] .player-track-name { color: #FF8C00; }
/* Artist name — South Park sky blue */
[data-theme='north-park'] .player-artist-name { color: #87CEEB; }
/* Content header — warm paper border */
[data-theme='north-park'] .content-header {
border-bottom: 1px solid rgba(27, 61, 110, 0.12);
}
/* Cards — clean white cutouts with paper shadow */
[data-theme='north-park'] .album-card,
[data-theme='north-park'] .artist-card {
background: #FFFFFF;
box-shadow: 0 1px 4px rgba(0, 20, 60, 0.08);
}
[data-theme='north-park'] .album-card:hover,
[data-theme='north-park'] .artist-card:hover {
box-shadow: 0 0 0 2px #FF8C00, 0 4px 16px rgba(0, 20, 60, 0.12);
}
/* Track rows (light main area) */
[data-theme='north-park'] .track-row:hover {
background: #EDE9DE;
box-shadow: inset 2px 0 0 #FF8C00;
}
/* Queue items (dark blue sidebar — keep bg dark, text stays white) */
[data-theme='north-park'] .queue-item:hover {
background: rgba(255, 255, 255, 0.09);
box-shadow: inset 2px 0 0 #FF8C00;
}
/* Flat primary button — orange, no gradient */
[data-theme='north-park'] .player-btn-primary {
background: #FF8C00;
color: #FFFFFF;
box-shadow: none;
border: none;
}
[data-theme='north-park'] .player-btn-primary:hover {
background: #E07800;
box-shadow: 0 0 14px rgba(255, 140, 0, 0.40);
}
[data-theme='north-park'] .btn-primary {
background: #FF8C00;
color: #FFFFFF;
border: none;
}
[data-theme='north-park'] .btn-primary:hover { background: #E07800; }
[data-theme='north-park'] .hero-play-btn {
background: #FF8C00;
color: #FFFFFF;
border: none;
box-shadow: 0 2px 14px rgba(255, 140, 0, 0.35);
}
[data-theme='north-park'] .hero-play-btn:hover {
background: #E07800;
box-shadow: 0 4px 22px rgba(255, 140, 0, 0.55);
transform: scale(1.02);
}
[data-theme='north-park'] .album-card-details-btn {
background: #FF8C00;
color: #FFFFFF;
border: none;
box-shadow: 0 2px 10px rgba(255, 140, 0, 0.30);
}
/* Badge — orange pill */
[data-theme='north-park'] .badge,
[data-theme='north-park'] .album-detail-badge {
background: #FF8C00;
color: #FFFFFF;
}
/* Settings tabs */
[data-theme='north-park'] .settings-tab:hover { background: rgba(255, 140, 0, 0.08); color: #FF8C00; }
[data-theme='north-park'] .settings-tab.active { background: rgba(255, 140, 0, 0.13); color: #FF8C00; border-color: #FF8C00; }
[data-theme='north-park'] .queue-tab-btn:hover { background: rgba(255, 255, 255, 0.10); color: rgba(255, 255, 255, 0.90); }
/* Misc interactive */
[data-theme='north-park'] .custom-select-trigger:hover { background: #EDE9DE; }
[data-theme='north-park'] .custom-select-option:hover { background: rgba(255, 140, 0, 0.10); }
[data-theme='north-park'] .artist-row:hover { background: rgba(255, 140, 0, 0.07); box-shadow: inset 3px 0 0 #FF8C00; }
[data-theme='north-park'] .playlist-row:hover { background: rgba(255, 140, 0, 0.07); box-shadow: inset 3px 0 0 #FF8C00; }
/* Connection indicators — muted on light bg */
[data-theme='north-park'] .connection-type,
[data-theme='north-park'] .connection-server { color: #7A8A9A; }
/* Queue panel — dark sidebar, light text overrides */
[data-theme='north-park'] .queue-panel { background: #1B3D6E; }
[data-theme='north-park'] .queue-item-title { color: rgba(255, 255, 255, 0.90); }
[data-theme='north-park'] .queue-item-artist,
[data-theme='north-park'] .queue-item-duration { color: rgba(255, 255, 255, 0.52); }
[data-theme='north-park'] .queue-item.active .queue-item-artist,
[data-theme='north-park'] .queue-item.active .queue-item-duration { color: rgba(255, 255, 255, 0.72); }
[data-theme='north-park'] .queue-divider span { color: rgba(255, 255, 255, 0.38) !important; }
[data-theme='north-park'] .queue-current-info h3 { color: rgba(255, 255, 255, 0.92); }
[data-theme='north-park'] .queue-current-sub { color: rgba(255, 255, 255, 0.55); }
[data-theme='north-park'] .queue-current-cover .fallback { color: rgba(255, 255, 255, 0.32); }
[data-theme='north-park'] .queue-tab-btn { color: rgba(255, 255, 255, 0.58); }
[data-theme='north-park'] .queue-tab-btn.active { color: #FF8C00; }
[data-theme='north-park'] .queue-round-btn.active { color: #FFFFFF; }
[data-theme='north-park'] .queue-action-btn { color: rgba(255, 255, 255, 0.60); }
[data-theme='north-park'] .queue-action-btn:hover:not(:disabled) { color: rgba(255, 255, 255, 0.92); }
/* Back button + hero text — needs high contrast on dark overlay at top of album header */
[data-theme='north-park'] .album-detail-back { color: rgba(255, 255, 255, 0.85); }
[data-theme='north-park'] .album-detail-back:hover { background: rgba(255, 255, 255, 0.12); color: #ffffff; }
[data-theme='north-park'] .album-detail-info { color: rgba(255, 255, 255, 0.80); }
/* Artist name in hero — soften the orange to a warm amber, less glaring */
[data-theme='north-park'] .album-detail-artist,
[data-theme='north-park'] .album-detail-artist-link { color: #FFAB40; }
[data-theme='north-park'] .album-detail-artist-link:hover { color: #FFD080; }
/* np-album-track (Now Playing page) */
[data-theme='north-park'] .np-album-track.active .np-album-track-title,
[data-theme='north-park'] .np-album-track.active .np-album-track-num { color: #FF8C00; }
/* Scrollbar — thin, orange thumb */
[data-theme='north-park'] ::-webkit-scrollbar { width: 5px; height: 5px; }
[data-theme='north-park'] ::-webkit-scrollbar-track { background: #EDE9DE; }
[data-theme='north-park'] ::-webkit-scrollbar-thumb { background: rgba(255, 140, 0, 0.35); border-radius: 3px; }
[data-theme='north-park'] ::-webkit-scrollbar-thumb:hover { background: #FF8C00; }
/* ─── Dark Side of the Moon (inspired) — Pink Floyd ─── */
/* Pure void-black + prism spectrum. No colour except the rainbow.
Accent: spectrum cyan. Spectrum gradient as player-bar top border.
Track name: white (the input light beam). Performance: no patterns, no animations. */
[data-theme='dark-side-of-the-moon'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%239B30FF%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* ── Palette — void black + prism spectrum ── */
--ctp-crust: #000000;
--ctp-mantle: #010101;
--ctp-base: #050505;
--ctp-surface0: #0A0A0A;
--ctp-surface1: #111111;
--ctp-surface2: #1A1A1A;
--ctp-overlay0: #333333;
--ctp-overlay1: #4A4A4A;
--ctp-overlay2: #686868;
--ctp-text: #F0F0F0;
--ctp-subtext1: #C8C8C8;
--ctp-subtext0: #A0A0A0;
--ctp-mauve: #9B30FF; /* spectrum violet — primary accent */
--ctp-lavender: #7B00FF; /* spectrum violet */
--ctp-pink: #FF2400; /* spectrum red */
--ctp-flamingo: #FF7700; /* spectrum orange */
--ctp-rosewater:#FFFFFF; /* the input light beam */
--ctp-green: #72FF00; /* spectrum green */
--ctp-teal: #00C8FF;
--ctp-sky: #9B30FF;
--ctp-blue: #0040FF; /* spectrum blue */
--ctp-sapphire: #0060DD;
--ctp-yellow: #FFE600; /* spectrum yellow */
--ctp-peach: #FF7700;
--ctp-maroon: #CC1800;
--ctp-red: #FF2400;
--bg-app: #050505;
--bg-sidebar: #020202;
--bg-card: #0D0D0D;
--bg-hover: #141414;
--bg-player: #010101;
--bg-glass: rgba(5, 5, 5, 0.96);
--accent: #9B30FF; /* spectrum violet */
--accent-dim: rgba(155, 48, 255, 0.10);
--accent-glow: rgba(155, 48, 255, 0.28);
--text-primary: #F0F0F0;
--text-secondary:#A0A0A0;
--text-muted: #555555;
--border: rgba(255, 255, 255, 0.07);
--border-subtle: rgba(255, 255, 255, 0.04);
--border-dropdown: rgba(155, 48, 255, 0.22);
--shadow-dropdown: rgba(0, 0, 0, 0.99);
--positive: #72FF00;
--warning: #FFE600;
--danger: #FF2400;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 6px;
/* Waveform — cyan played, dark unplayed */
--waveform-played: #9B30FF;
--waveform-buffered: rgba(255, 255, 255, 0.07);
--waveform-unplayed: rgba(255, 255, 255, 0.04);
/* Logo — orange → cyan (the spectrum itself) */
--logo-color-start: #FF7700;
--logo-color-end: #9B30FF;
}
/* Player bar — void black, spectrum rainbow top border via ::before */
[data-theme='dark-side-of-the-moon'] .player-bar {
background: #010101;
border-top: none;
position: relative;
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.04);
}
[data-theme='dark-side-of-the-moon'] .player-bar::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg,
#FF2400 0%,
#FF7700 17%,
#FFE600 33%,
#72FF00 50%,
#00C8FF 67%,
#0040FF 83%,
#7B00FF 100%
);
}
/* Track name — the white input light beam */
[data-theme='dark-side-of-the-moon'] .player-track-name {
color: #FFFFFF;
}
/* Artist name — neutral dim */
[data-theme='dark-side-of-the-moon'] .player-artist-name {
color: #888888;
}
/* Sidebar — pure void */
[data-theme='dark-side-of-the-moon'] .sidebar {
background: #020202;
border-right: 1px solid rgba(255, 255, 255, 0.04);
}
[data-theme='dark-side-of-the-moon'] .nav-link.active {
background: rgba(0, 229, 255, 0.08);
border-left: 3px solid #00E5FF;
color: #00E5FF;
}
[data-theme='dark-side-of-the-moon'] .nav-link:hover:not(.active) {
background: rgba(255, 255, 255, 0.04);
border-left: 3px solid rgba(0, 229, 255, 0.30);
color: #F0F0F0;
}
/* Cards */
[data-theme='dark-side-of-the-moon'] .album-card,
[data-theme='dark-side-of-the-moon'] .artist-card {
background: #0D0D0D;
}
[data-theme='dark-side-of-the-moon'] .album-card:hover,
[data-theme='dark-side-of-the-moon'] .artist-card:hover {
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.40), 0 4px 20px rgba(0, 229, 255, 0.08);
}
/* Track / queue rows */
[data-theme='dark-side-of-the-moon'] .track-row:hover,
[data-theme='dark-side-of-the-moon'] .queue-item:hover {
background: #141414;
box-shadow: inset 2px 0 0 #00E5FF;
}
/* Buttons — flat cyan, no gradient */
[data-theme='dark-side-of-the-moon'] .player-btn-primary {
background: #00E5FF;
color: #010101;
box-shadow: none;
border: none;
}
[data-theme='dark-side-of-the-moon'] .player-btn-primary:hover {
background: #33EEFF;
box-shadow: 0 0 16px rgba(0, 229, 255, 0.40);
}
[data-theme='dark-side-of-the-moon'] .btn-primary {
background: #00E5FF;
color: #010101;
border: none;
}
[data-theme='dark-side-of-the-moon'] .btn-primary:hover { background: #33EEFF; }
[data-theme='dark-side-of-the-moon'] .hero-play-btn {
background: #00E5FF;
color: #010101;
border: none;
box-shadow: 0 2px 16px rgba(0, 229, 255, 0.30);
}
[data-theme='dark-side-of-the-moon'] .hero-play-btn:hover {
background: #33EEFF;
box-shadow: 0 4px 26px rgba(0, 229, 255, 0.50);
transform: scale(1.02);
}
[data-theme='dark-side-of-the-moon'] .album-card-details-btn {
background: #00E5FF;
color: #010101;
border: none;
box-shadow: 0 2px 10px rgba(0, 229, 255, 0.25);
}
/* Badge */
[data-theme='dark-side-of-the-moon'] .badge,
[data-theme='dark-side-of-the-moon'] .album-detail-badge {
background: #00E5FF;
color: #010101;
}
/* Settings + queue tabs */
[data-theme='dark-side-of-the-moon'] .settings-tab:hover { background: rgba(155, 48, 255, 0.07); color: #9B30FF; }
[data-theme='dark-side-of-the-moon'] .settings-tab.active { background: rgba(155, 48, 255, 0.12); color: #9B30FF; border-color: #9B30FF; }
[data-theme='dark-side-of-the-moon'] .queue-tab-btn:hover { background: rgba(255, 255, 255, 0.05); }
[data-theme='dark-side-of-the-moon'] .queue-tab-btn.active { color: #9B30FF; }
[data-theme='dark-side-of-the-moon'] .queue-round-btn.active { color: #010101; }
/* Misc interactive */
[data-theme='dark-side-of-the-moon'] .custom-select-trigger:hover { background: #141414; }
[data-theme='dark-side-of-the-moon'] .custom-select-option:hover { background: rgba(155, 48, 255, 0.08); }
[data-theme='dark-side-of-the-moon'] .artist-row:hover { background: rgba(155, 48, 255, 0.05); box-shadow: inset 3px 0 0 #9B30FF; }
[data-theme='dark-side-of-the-moon'] .playlist-row:hover { background: rgba(155, 48, 255, 0.05); box-shadow: inset 3px 0 0 #9B30FF; }
[data-theme='dark-side-of-the-moon'] .artist-ext-link:hover { background: rgba(155, 48, 255, 0.07); border-color: #9B30FF; }
/* Album detail header — year/genre/info too dark on near-black bg, brighten */
[data-theme='dark-side-of-the-moon'] .album-detail-info { color: #888888; }
/* np-album-track */
[data-theme='dark-side-of-the-moon'] .np-album-track.active .np-album-track-title,
[data-theme='dark-side-of-the-moon'] .np-album-track.active .np-album-track-num { color: #9B30FF; }
/* Scrollbar — minimal, cyan thumb */
[data-theme='dark-side-of-the-moon'] ::-webkit-scrollbar { width: 4px; height: 4px; }
[data-theme='dark-side-of-the-moon'] ::-webkit-scrollbar-track { background: #020202; }
[data-theme='dark-side-of-the-moon'] ::-webkit-scrollbar-thumb { background: rgba(155, 48, 255, 0.25); border-radius: 2px; }
[data-theme='dark-side-of-the-moon'] ::-webkit-scrollbar-thumb:hover { background: #9B30FF; }
/* Connection indicators */
[data-theme='dark-side-of-the-moon'] .connection-type { color: #999999; }
[data-theme='dark-side-of-the-moon'] .connection-server { color: #777777; }
/* ─── Powerslave (inspired) — Iron Maiden ─── */
/* Blazing Egyptian sun warm sandstone main, Nile-sky deep-blue sidebar,
pharaoh gold accent, gold+blue duality. Light, no dark mode.
Sidebar: deep Nile blue with subtle hieroglyph grid. Player bar: navy + gold cornice. */
[data-theme='powerslave'] {
color-scheme: light;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23C8800A%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* ── Palette — sandstone, pharaoh gold, temple shadows ── */
--ctp-crust: #180E04;
--ctp-mantle: #2A1808;
--ctp-base: #F0DFB0; /* warm sandstone */
--ctp-surface0: #E8D49A;
--ctp-surface1: #DEC880;
--ctp-surface2: #D0B860;
--ctp-overlay0: #A07830;
--ctp-overlay1: #806020;
--ctp-overlay2: #604810;
--ctp-text: #2A1808; /* warm dark brown */
--ctp-subtext1: #4A2C10;
--ctp-subtext0: #6A4018;
--ctp-mauve: #C8800A; /* pharaoh gold */
--ctp-lavender: #D49020;
--ctp-pink: #B04000;
--ctp-flamingo: #CC4800;
--ctp-rosewater:#F8EDD0;
--ctp-green: #507820;
--ctp-teal: #207870; /* Egyptian turquoise */
--ctp-sky: #3080A0;
--ctp-blue: #1A5080;
--ctp-sapphire: #204870;
--ctp-yellow: #C8800A;
--ctp-peach: #C05010;
--ctp-maroon: #801808;
--ctp-red: #A82000;
--bg-app: #F0DFB0; /* sun-bleached sandstone */
--bg-sidebar: #0A2035; /* Nile sky — deep lapis blue */
--bg-card: #F8EDD0;
--bg-hover: #E8D49A;
--bg-player: #06101C; /* deep night Nile */
--bg-glass: rgba(240, 223, 176, 0.94);
--accent: #C8960C; /* pharaoh gold */
--accent-dim: rgba(200, 150, 12, 0.13);
--accent-glow: rgba(200, 150, 12, 0.30);
--text-primary: #2A1808;
--text-secondary:#6A4018;
--text-muted: #A07830;
--border: rgba(42, 24, 8, 0.14);
--border-subtle: rgba(42, 24, 8, 0.08);
--border-dropdown: rgba(200, 128, 10, 0.28);
--shadow-dropdown: rgba(24, 14, 4, 0.22);
--positive: #507820;
--warning: #C05010;
--danger: #A82000;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 6px;
--waveform-played: #2E7BB8; /* Nile azure — the sky */
--waveform-buffered: rgba(46, 123, 184, 0.20);
--waveform-unplayed: rgba(42, 24, 8, 0.10);
/* Logo — Nile blue → pharaoh gold */
--logo-color-start: #2E7BB8;
--logo-color-end: #C8960C;
}
/* Sidebar — Nile-sky deep blue, subtle hieroglyph grid */
[data-theme='powerslave'] .sidebar {
background: #0A2035;
border-right: none;
background-image:
repeating-linear-gradient(
0deg,
rgba(46, 123, 184, 0.08) 0px, transparent 1px, transparent 48px,
rgba(46, 123, 184, 0.08) 49px
),
repeating-linear-gradient(
90deg,
rgba(46, 123, 184, 0.05) 0px, transparent 1px, transparent 96px,
rgba(46, 123, 184, 0.05) 97px
);
}
[data-theme='powerslave'] .nav-link { color: rgba(200, 224, 248, 0.65); }
[data-theme='powerslave'] .nav-link.active {
background: linear-gradient(90deg, rgba(200, 150, 12, 0.22) 0%, transparent 100%);
border-left: 3px solid #C8960C;
color: #D49828;
}
[data-theme='powerslave'] .nav-link:hover:not(.active) {
background: rgba(46, 123, 184, 0.12);
border-left: 3px solid rgba(46, 123, 184, 0.50);
color: rgba(200, 224, 248, 0.92);
}
/* Player bar — deep Nile night, gold cornice */
[data-theme='powerslave'] .player-bar {
background: #06101C;
border-top: 2px solid #C8960C;
box-shadow: 0 -4px 24px rgba(46, 123, 184, 0.10), 0 -1px 0 rgba(200, 150, 12, 0.20);
}
/* Track name — warm gold glow */
[data-theme='powerslave'] .player-track-name {
color: #D49828;
text-shadow: 0 0 12px rgba(200, 150, 12, 0.40);
}
/* Artist name — Nile azure */
[data-theme='powerslave'] .player-artist-name { color: #6AAED8; }
/* Content header */
[data-theme='powerslave'] .content-header {
border-bottom: 1px solid rgba(42, 24, 8, 0.12);
}
/* Cards — parchment on sandstone */
[data-theme='powerslave'] .album-card,
[data-theme='powerslave'] .artist-card {
background: #F8EDD0;
box-shadow: 0 1px 4px rgba(42, 24, 8, 0.10);
}
[data-theme='powerslave'] .album-card:hover,
[data-theme='powerslave'] .artist-card:hover {
box-shadow: 0 0 0 2px #C8800A, 0 4px 16px rgba(42, 24, 8, 0.12);
}
/* Track / queue rows */
[data-theme='powerslave'] .track-row:hover {
background: #E8D49A;
box-shadow: inset 2px 0 0 #C8800A;
}
[data-theme='powerslave'] .queue-item:hover {
background: rgba(46, 123, 184, 0.12);
box-shadow: inset 2px 0 0 #2E7BB8;
}
/* Buttons — gold gradient */
[data-theme='powerslave'] .player-btn-primary {
background: linear-gradient(180deg, #D49020 0%, #C8800A 50%, #A06008 100%);
color: #180E04;
border: 1px solid #806006;
box-shadow: 0 0 12px rgba(200, 128, 10, 0.28);
}
[data-theme='powerslave'] .player-btn-primary:hover {
background: linear-gradient(180deg, #E0A028 0%, #D48818 50%, #B07010 100%);
box-shadow: 0 0 22px rgba(200, 128, 10, 0.48);
}
[data-theme='powerslave'] .btn-primary {
background: linear-gradient(180deg, #D49020 0%, #C8800A 50%, #A06008 100%);
color: #180E04;
border: 1px solid #806006;
}
[data-theme='powerslave'] .btn-primary:hover {
background: linear-gradient(180deg, #E0A028 0%, #D48818 50%, #B07010 100%);
}
[data-theme='powerslave'] .hero-play-btn {
background: linear-gradient(180deg, #D49020 0%, #C8800A 50%, #A06008 100%);
color: #180E04;
border: 1px solid #806006;
box-shadow: 0 2px 16px rgba(200, 128, 10, 0.28);
}
[data-theme='powerslave'] .hero-play-btn:hover {
background: linear-gradient(180deg, #E0A028 0%, #D48818 50%, #B07010 100%);
box-shadow: 0 4px 26px rgba(200, 128, 10, 0.48);
transform: scale(1.02);
}
[data-theme='powerslave'] .album-card-details-btn {
background: linear-gradient(180deg, #D49020 0%, #C8800A 50%, #A06008 100%);
color: #180E04;
border: 1px solid #806006;
}
/* Badge — gold pill */
[data-theme='powerslave'] .badge,
[data-theme='powerslave'] .album-detail-badge {
background: #C8800A;
color: #F8EDD0;
}
/* Settings + queue */
[data-theme='powerslave'] .settings-tab:hover { background: rgba(200, 150, 12, 0.08); color: #C8960C; }
[data-theme='powerslave'] .settings-tab.active { background: rgba(200, 150, 12, 0.14); color: #C8960C; border-color: #C8960C; }
[data-theme='powerslave'] .queue-tab-btn:hover { background: rgba(46, 123, 184, 0.12); color: rgba(200, 224, 248, 0.92); }
[data-theme='powerslave'] .queue-tab-btn.active { color: #C8960C; }
[data-theme='powerslave'] .queue-round-btn.active { color: #06101C; }
/* Queue panel text — Nile-blue sidebar */
[data-theme='powerslave'] .queue-panel { background: #0A2035; }
[data-theme='powerslave'] .queue-item-title { color: rgba(220, 235, 248, 0.90); }
[data-theme='powerslave'] .queue-item-artist,
[data-theme='powerslave'] .queue-item-duration { color: rgba(140, 190, 230, 0.55); }
[data-theme='powerslave'] .queue-item.active .queue-item-artist,
[data-theme='powerslave'] .queue-item.active .queue-item-duration { color: rgba(140, 190, 230, 0.75); }
[data-theme='powerslave'] .queue-divider span { color: rgba(140, 190, 230, 0.38) !important; }
[data-theme='powerslave'] .queue-current-info h3 { color: rgba(220, 235, 248, 0.92); }
[data-theme='powerslave'] .queue-current-sub { color: rgba(140, 190, 230, 0.58); }
[data-theme='powerslave'] .queue-current-cover .fallback { color: rgba(140, 190, 230, 0.30); }
[data-theme='powerslave'] .queue-action-btn { color: rgba(140, 190, 230, 0.65); }
[data-theme='powerslave'] .queue-action-btn:hover:not(:disabled) { color: rgba(220, 235, 248, 0.95); }
/* Misc interactive */
[data-theme='powerslave'] .custom-select-trigger:hover { background: #E8D49A; }
[data-theme='powerslave'] .custom-select-option:hover { background: rgba(200, 128, 10, 0.10); }
[data-theme='powerslave'] .artist-row:hover { background: rgba(200, 128, 10, 0.07); box-shadow: inset 3px 0 0 #C8800A; }
[data-theme='powerslave'] .playlist-row:hover { background: rgba(200, 128, 10, 0.07); box-shadow: inset 3px 0 0 #C8800A; }
[data-theme='powerslave'] .artist-ext-link:hover { background: rgba(200, 128, 10, 0.08); border-color: #C8800A; }
/* Connection indicators */
[data-theme='powerslave'] .connection-type { color: #050E19; }
[data-theme='powerslave'] .connection-server { color: #050E19; }
/* Back button needs light color — sits on dark overlay above sandstone content */
[data-theme='powerslave'] .album-detail-back { color: rgba(255, 255, 255, 0.85); }
[data-theme='powerslave'] .album-detail-back:hover { background: rgba(255, 255, 255, 0.12); color: #ffffff; }
/* Tech strip — default ctp-surface1 (#DEC880) is too bright on dark blue queue panel */
[data-theme='powerslave'] .queue-current-tech {
background: rgba(6, 16, 28, 0.88);
color: rgba(140, 190, 230, 0.72);
}
/* Album detail hero meta */
[data-theme='powerslave'] .album-detail-info { color: rgba(255, 255, 255, 0.80); }
[data-theme='powerslave'] .album-detail-artist,
[data-theme='powerslave'] .album-detail-artist-link { color: #050E19; }
[data-theme='powerslave'] .album-detail-artist-link:hover { color: #0A2035; }
/* np-album-track */
[data-theme='powerslave'] .np-album-track.active .np-album-track-title,
[data-theme='powerslave'] .np-album-track.active .np-album-track-num { color: #C8960C; }
/* Scrollbar — warm, gold thumb */
[data-theme='powerslave'] ::-webkit-scrollbar { width: 5px; height: 5px; }
[data-theme='powerslave'] ::-webkit-scrollbar-track { background: #E8D49A; }
[data-theme='powerslave'] ::-webkit-scrollbar-thumb { background: rgba(200, 128, 10, 0.35); border-radius: 3px; }
[data-theme='powerslave'] ::-webkit-scrollbar-thumb:hover { background: #C8800A; }
+76
View File
@@ -0,0 +1,76 @@
/**
* Creates a slim, themed drag ghost and registers it via setDragImage.
* The element is cleaned up when the drag completes (dragend).
*
* On Linux (WebKitGTK), the GTK drag subsystem captures the drag image
* asynchronously removing the element in the same tick or via
* setTimeout(, 0) causes the image to be gone before GTK reads it,
* which makes GTK treat the entire drag as invalid (forbidden cursor).
* Using a dragend listener ensures the element persists for the full
* duration of the drag operation on all platforms.
*
* @param dataTransfer - the event's dataTransfer object
* @param label - primary text (track/album title)
* @param opts.coverUrl - optional thumbnail URL (shown as 24×24 image)
*/
export function setDragGhost(
dataTransfer: DataTransfer,
label: string,
opts: { coverUrl?: string } = {},
): void {
const el = document.createElement('div');
el.style.cssText = [
'position:fixed',
'left:-9999px',
'top:-9999px',
'display:flex',
'align-items:center',
'gap:8px',
`padding:0 14px 0 ${opts.coverUrl ? '6px' : '10px'}`,
'height:34px',
'max-width:240px',
'border-radius:17px',
'background:var(--bg-card,#fff)',
'border:1px solid var(--border,rgba(0,0,0,.12))',
'border-left:3px solid var(--accent,#888)',
'box-shadow:0 4px 20px rgba(0,0,0,.22)',
'font-family:var(--font-ui,sans-serif)',
'font-size:13px',
'font-weight:500',
'color:var(--text-primary,#222)',
'pointer-events:none',
'white-space:nowrap',
'overflow:hidden',
'z-index:99999',
].join(';');
if (opts.coverUrl) {
const img = document.createElement('img');
img.src = opts.coverUrl;
img.style.cssText = 'width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0;';
el.appendChild(img);
} else {
const dot = document.createElement('span');
dot.style.cssText = 'width:6px;height:6px;border-radius:50%;background:var(--accent,#888);flex-shrink:0;';
el.appendChild(dot);
}
const text = document.createElement('span');
text.textContent = label;
text.style.cssText = 'overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;';
el.appendChild(text);
document.body.appendChild(el);
dataTransfer.setDragImage(el, 20, 17);
// Clean up the ghost element when the drag ends, not immediately.
// This is critical for Linux/WebKitGTK where the drag image is
// captured asynchronously by GTK — removing it sooner causes
// the "forbidden" cursor and blocks the entire drop operation.
const cleanup = () => {
el.remove();
document.removeEventListener('dragend', cleanup);
};
document.addEventListener('dragend', cleanup, { once: true });
}