diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48ef344e..7c008352 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,32 @@ 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.24.0] - 2026-03-31
+
+### Added
+
+- **Playlist Management** *(requested by [@adirav02](https://github.com/adirav02))*: Full playlist management feature:
+ - **Playlists overview page** (`/playlists`): card grid showing all server playlists with cover collage, song count and duration. Inline "New Playlist" creation (Enter to confirm, Escape to cancel). Two-click delete confirmation directly on the card.
+ - **Playlist detail page** (`/playlists/:id`): hero area with 2×2 album cover collage and blurred background (matching Album Detail style), full tracklist with drag-and-drop reordering, star ratings, codec labels, per-row delete button, and context menu.
+ - **Song search**: "Add Songs" button opens an inline search panel with debounced server search, thumbnail, artist · album info, and a round add button (accent on hover). Duplicate songs already in the playlist are filtered from results.
+ - **Suggestions**: "Suggested Songs" section below the tracklist loads similar songs via `getSimilarSongs2` based on the first artist in the playlist. Refresh button to load a new batch. Same tracklist layout as search results.
+ - **Context menu — Add to Playlist**: "Add to Playlist" submenu available on all song/album/queue-item context menus. Playlists sorted by most recently used. "New Playlist" inline create at the top of the submenu. Submenu flips left when near the right viewport edge.
+ - **Sidebar**: Playlists navigation entry added between Favorites and Statistics.
+ - **Recently used playlist tracking**: `playlistStore` (persisted) tracks the last 50 used playlist IDs for the context menu sort order.
+
+### Fixed
+
+- **Resampling — first track played at native sample rate** *(reported by [@sorensiimSalling](https://github.com/sorensiimSalling))*: `current_sample_rate` was initialized to `44100`, causing every track to be resampled down to 44.1 kHz on playback start. Initializing to `0` disables resampling until the actual track rate is known.
+- **Resampling — no application-level resampling for any track**: `target_rate` in `audio_play` and `audio_chain_next` is now always `0`. Previously, tracks after the first were resampled to match the first track's sample rate. Rodio handles conversion to the output device rate internally; every track now plays at its native sample rate.
+- **Playlist hero background flickering**: The blurred hero background in Playlist Detail flickered on every render because `buildCoverArtUrl()` generates a new random salt on every call, causing `useCachedUrl` to re-trigger in a loop. The fetch URL and cache key are now `useMemo`-stabilised.
+- **Input focus double border**: The playlist name and song search inputs used a `search-input` class that had no CSS definition, falling back to browser defaults. The global `:focus-visible` rule then added a second outline on top of the browser's own focus ring. Switched to the `.input` class which sets `outline: none` and uses `border-color` + glow on focus.
+
+### Changed
+
+- **Playlist search panel**: Redesigned with `surface-2` background, `radius-lg`, slide-down open animation, 36 px thumbnails, artist · album subtitle line, and a round icon add-button (accent colour on hover) replacing the generic `btn-surface` button.
+
+---
+
## [1.23.0] - 2026-03-30
### Added
diff --git a/package.json b/package.json
index e9b86547..be42e8c3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "psysonic",
- "version": "1.23.0",
+ "version": "1.24.0",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 3d4d5519..6e849d05 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "psysonic"
-version = "1.21.0"
+version = "1.24.0"
dependencies = [
"biquad",
"md5",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index aa06ec80..52c2e79c 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
-version = "1.21.0"
+version = "1.24.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs
index 3156ef78..62f5b7e1 100644
--- a/src-tauri/src/audio.rs
+++ b/src-tauri/src/audio.rs
@@ -1148,7 +1148,9 @@ pub async fn audio_play(
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
- let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
+ // Always 0 — no application-level resampling. Rodio handles conversion to
+ // the output device rate internally; we let every track play at its native rate.
+ let target_rate: u32 = 0;
// Extract format hint from URL for better symphonia probing.
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
@@ -1343,7 +1345,8 @@ pub async fn audio_chain_preload(
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
- let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
+ // Always 0 — no application-level resampling (same as audio_play).
+ let target_rate: u32 = 0;
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 1375c094..2cd60131 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.23.0",
+ "version": "1.24.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/src/App.tsx b/src/App.tsx
index 8b1e7476..c0066a98 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -26,6 +26,8 @@ import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import SearchResults from './pages/SearchResults';
import AdvancedSearch from './pages/AdvancedSearch';
+import Playlists from './pages/Playlists';
+import PlaylistDetail from './pages/PlaylistDetail';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
@@ -251,6 +253,8 @@ function AppShell() {
} />
} />
} />
+ } />
+ } />
diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts
index 17a79422..806bd359 100644
--- a/src/api/subsonic.ts
+++ b/src/api/subsonic.ts
@@ -384,12 +384,13 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
return { playlist, songs: entry ?? [] };
}
-export async function createPlaylist(name: string, songIds?: string[]): Promise {
+export async function createPlaylist(name: string, songIds?: string[]): Promise {
const params: Record = { name };
if (songIds && songIds.length > 0) {
params.songId = songIds;
}
- await api('createPlaylist.view', params);
+ const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
+ return data.playlist;
}
export async function updatePlaylist(id: string, songIds: string[]): Promise {
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx
index f71a98d9..9e7ecd7f 100644
--- a/src/components/ContextMenu.tsx
+++ b/src/components/ContextMenu.tsx
@@ -1,11 +1,12 @@
-import React, { useEffect, useRef, useState } from 'react';
-import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react';
+import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart, ListMusic, Plus } from 'lucide-react';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
-import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
+import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
+import { usePlaylistStore } from '../store/playlistStore';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
@@ -19,6 +20,144 @@ function sanitizeFilename(name: string): string {
.substring(0, 200) || 'download';
}
+// ── Add-to-Playlist submenu ───────────────────────────────────────
+function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
+ const { t } = useTranslation();
+ const subRef = useRef(null);
+ const newNameRef = useRef(null);
+ const [playlists, setPlaylists] = useState([]);
+ const [adding, setAdding] = useState(null);
+ const [creating, setCreating] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [flipLeft, setFlipLeft] = useState(false);
+ const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
+ const recentIds = usePlaylistStore((s) => s.recentIds);
+
+ useEffect(() => {
+ getPlaylists().then((all) => {
+ const sorted = [...all].sort((a, b) => {
+ const ai = recentIds.indexOf(a.id);
+ const bi = recentIds.indexOf(b.id);
+ if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
+ if (ai === -1) return 1;
+ if (bi === -1) return -1;
+ return ai - bi;
+ });
+ setPlaylists(sorted);
+ }).catch(() => {});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Flip submenu left if it would overflow the right edge of the viewport
+ useLayoutEffect(() => {
+ if (subRef.current) {
+ const rect = subRef.current.getBoundingClientRect();
+ if (rect.right > window.innerWidth - 8) setFlipLeft(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (creating) newNameRef.current?.focus();
+ }, [creating]);
+
+ const handleAdd = async (pl: SubsonicPlaylist) => {
+ setAdding(pl.id);
+ try {
+ const { songs } = await getPlaylist(pl.id);
+ const existingIds = new Set(songs.map((s) => s.id));
+ const newIds = songIds.filter((id) => !existingIds.has(id));
+ if (newIds.length > 0) {
+ await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
+ }
+ touchPlaylist(pl.id);
+ } catch {}
+ setAdding(null);
+ onDone();
+ };
+
+ const handleCreate = async () => {
+ const name = newName.trim() || t('playlists.unnamed');
+ try {
+ const pl = await createPlaylist(name, songIds);
+ if (pl?.id) touchPlaylist(pl.id);
+ } catch {}
+ onDone();
+ };
+
+ const subStyle: React.CSSProperties = flipLeft
+ ? { right: 'calc(100% + 4px)', left: 'auto' }
+ : { left: 'calc(100% + 4px)', right: 'auto' };
+
+ return (
+