feat: v1.22.0 — Queue Management, DnD Overhaul, Seek & Waveform Fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-30 19:14:59 +02:00
parent 42863877f6
commit d6f6e6466c
17 changed files with 760 additions and 79 deletions
+31 -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';
@@ -162,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"
@@ -445,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">
+16 -4
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]);
@@ -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
+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>
+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 };
}
+15 -5
View File
@@ -359,7 +359,7 @@ 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.",
@@ -492,6 +492,8 @@ const enTranslation = {
queue: {
title: 'Queue',
savePlaylist: 'Save Playlist',
updatePlaylist: 'Update Playlist',
filterPlaylists: 'Filter playlists…',
playlistName: 'Playlist Name',
cancel: 'Cancel',
save: 'Save',
@@ -934,7 +936,7 @@ 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.',
@@ -1067,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',
@@ -1509,7 +1513,7 @@ 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.',
@@ -1642,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',
@@ -2084,7 +2090,7 @@ 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.',
@@ -2217,6 +2223,8 @@ const nlTranslation = {
queue: {
title: 'Wachtrij',
savePlaylist: 'Afspeellijst opslaan',
updatePlaylist: 'Afspeellijst bijwerken',
filterPlaylists: 'Afspeellijsten filteren…',
playlistName: 'Naam afspeellijst',
cancel: 'Annuleren',
save: 'Opslaan',
@@ -2659,7 +2667,7 @@ const zhTranslation = {
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
aboutContributorsLabel: '贡献者',
aboutContributors: 'jiezhuo — 中文翻译',
aboutContributors: 'jiezhuo — 中文翻译 · nullobject — Seek & 波形修复',
changelog: '更新日志',
showChangelogOnUpdate: '更新时显示"新功能"',
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
@@ -2792,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' }}>
+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
+17
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;
@@ -690,6 +691,22 @@ 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;
+5
View File
@@ -759,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;
+32 -7
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));
@@ -11200,7 +11210,9 @@ input[type="range"]:hover::-webkit-slider-thumb {
[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); }
/* Hero album meta (year, genre etc.) — needs high contrast on dark overlay */
/* 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 */
@@ -11419,6 +11431,9 @@ input[type="range"]:hover::-webkit-slider-thumb {
[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; }
@@ -11430,8 +11445,8 @@ input[type="range"]:hover::-webkit-slider-thumb {
[data-theme='dark-side-of-the-moon'] ::-webkit-scrollbar-thumb:hover { background: #9B30FF; }
/* Connection indicators */
[data-theme='dark-side-of-the-moon'] .connection-type,
[data-theme='dark-side-of-the-moon'] .connection-server { color: #555555; }
[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,
@@ -11654,14 +11669,24 @@ input[type="range"]:hover::-webkit-slider-thumb {
[data-theme='powerslave'] .artist-ext-link:hover { background: rgba(200, 128, 10, 0.08); border-color: #C8800A; }
/* Connection indicators */
[data-theme='powerslave'] .connection-type,
[data-theme='powerslave'] .connection-server { color: rgba(140, 190, 230, 0.70); }
[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: #D49828; }
[data-theme='powerslave'] .album-detail-artist-link:hover { color: #E8B040; }
[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,
+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 });
}