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 (