diff --git a/CHANGELOG.md b/CHANGELOG.md index 415bc8ef..95eb9e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ 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 diff --git a/package.json b/package.json index 5fcd16c8..8c6f6035 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.21.0", + "version": "1.22.0", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 99d25f3e..2cd1ba3b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.21.0", + "version": "1.22.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 5be34f09..5e9bdfc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 (
- + + + } /> diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 4fd30d32..17a79422 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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 { + // 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 { await api('deletePlaylist.view', { id }); } diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 6267769b..deae1365 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -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 (
{ - 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); }} >
diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 5c3f9318..0e23692c 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -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(null); const [contextMenuSongId, setContextMenuSongId] = useState(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); }} >
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([]); 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 ( + <>
e.stopPropagation()} style={{ maxWidth: '400px' }}>

{t('queue.loadPlaylist')}

+ {!loading && playlists.length > 0 && ( + setFilter(e.target.value)} + autoFocus + style={{ width: '100%', marginBottom: '0.75rem', padding: '8px 14px' }} + /> + )} {loading ? (

{t('queue.loading')}

) : playlists.length === 0 ? (

{t('queue.noPlaylists')}

) : (
- {playlists.map(p => ( + {playlists.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())).map(p => (
{p.name}
- +
@@ -110,6 +129,25 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: ( )}
+ + {confirmDelete && ( +
setConfirmDelete(null)} role="dialog" aria-modal="true"> +
e.stopPropagation()} style={{ maxWidth: '360px' }}> + +

{t('queue.delete')}

+

+ {t('queue.deleteConfirm', { name: confirmDelete.name })} +

+
+ + +
+
+
+ )} + ); } @@ -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(null); const queueListRef = useRef(null); + const asideRef = useRef(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 (
{saveModalOpen && ( - setSaveModalOpen(false)} - onSave={async (name) => { + 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 && ( - setLoadModalOpen(false)} - onLoad={async (id) => { + 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); } - }} + }} /> )} diff --git a/src/contexts/DragDropContext.tsx b/src/contexts/DragDropContext.tsx new file mode 100644 index 00000000..ca54a6f2 --- /dev/null +++ b/src/contexts/DragDropContext.tsx @@ -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({ + 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( +
+ {coverUrl && ( + + )} + + {label} + +
, + document.body, + ); +} + +// ── Provider ────────────────────────────────────────────────────── +export function DragDropProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ + 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 ( + + {children} + + + ); +} + +// ── useDragSource hook ──────────────────────────────────────────── +const DRAG_THRESHOLD = 5; // px before drag starts + +/** + * Returns an onMouseDown handler for a draggable element. + * Usage:
+ */ +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 }; +} diff --git a/src/i18n.ts b/src/i18n.ts index 7a3463a4..4c8ecc75 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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: '保存', diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 757d597d..85f8b259 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -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); }} >
playTrack(song, songs)} style={{ cursor: 'pointer' }}> diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 8dec68f8..b6a824fb 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -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(null); + const psyDrag = useDragDrop(); const [starredSongs, setStarredSongs] = useState>(new Set()); const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore(); const [addedGenre, setAddedGenre] = useState(null); @@ -343,11 +345,22 @@ export default function RandomMix() {
{genreMixSongs.map(song => (
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); }} >