From c67d606f89717f7076f29e2c2ac3995444d9d30a Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 31 Mar 2026 23:14:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v1.24.0=20=E2=80=94=20Playlist=20Manage?= =?UTF-8?q?ment,=20native=20sample=20rate=20playback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full playlist feature: overview grid, detail page with hero collage, tracklist DnD, song search, suggestions, context menu submenu - Audio: disable all app-level resampling — every track plays at its native sample rate (target_rate always 0 in audio_play + chain_next) - Fix: playlist hero bg flicker (memoize buildCoverArtUrl calls) - Fix: input focus double-border (search-input → .input class) - Polish: redesigned playlist search panel Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 26 ++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/audio.rs | 7 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 4 + src/api/subsonic.ts | 5 +- src/components/ContextMenu.tsx | 182 +++++++++- src/components/Sidebar.tsx | 3 +- src/i18n.ts | 140 ++++++++ src/pages/PlaylistDetail.tsx | 614 +++++++++++++++++++++++++++++++++ src/pages/Playlists.tsx | 182 ++++++++++ src/store/playlistStore.ts | 23 ++ src/styles/components.css | 359 +++++++++++++++++++ 15 files changed, 1541 insertions(+), 12 deletions(-) create mode 100644 src/pages/PlaylistDetail.tsx create mode 100644 src/pages/Playlists.tsx create mode 100644 src/store/playlistStore.ts 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 ( +
+ {/* New Playlist row */} + {!creating ? ( +
{ e.stopPropagation(); setCreating(true); }} + > + {t('playlists.newPlaylist')} +
+ ) : ( +
e.stopPropagation()}> + setNewName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleCreate(); + if (e.key === 'Escape') { setCreating(false); setNewName(''); } + }} + /> + +
+ )} + +
+ + {playlists.length === 0 && ( +
{t('playlists.empty')}
+ )} + {playlists.map((pl) => ( +
handleAdd(pl)} + style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }} + > + + {pl.name} +
+ ))} +
+ ); +} + +// Same as AddToPlaylistSubmenu but resolves album songs first +function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: () => void }) { + const [resolvedIds, setResolvedIds] = useState(null); + + useEffect(() => { + getAlbum(albumId).then((data) => { + setResolvedIds(data.songs.map((s) => s.id)); + }).catch(() => setResolvedIds([])); + }, [albumId]); + + if (resolvedIds === null) { + return ( +
+
+
+ ); + } + if (resolvedIds.length === 0) return null; + return ; +} + export default function ContextMenu() { const { t } = useTranslation(); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore(); @@ -29,10 +168,14 @@ export default function ContextMenu() { // Adjusted coordinates to keep menu on screen const [coords, setCoords] = useState({ x: 0, y: 0 }); + const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false); + const [playlistSongIds, setPlaylistSongIds] = useState([]); useEffect(() => { if (contextMenu.isOpen) { setCoords({ x: contextMenu.x, y: contextMenu.y }); + setPlaylistSubmenuOpen(false); + setPlaylistSongIds([]); } }, [contextMenu.isOpen, contextMenu.x, contextMenu.y]); @@ -125,6 +268,17 @@ export default function ContextMenu() {
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
+
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={() => setPlaylistSubmenuOpen(false)} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
{type === 'album-song' && (
handleAction(async () => { const albumData = await getAlbum(song.albumId); @@ -192,6 +346,17 @@ export default function ContextMenu() {
handleAction(() => downloadAlbum(album.name, album.id))}> {t('contextMenu.download')}
+
{ setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={() => setPlaylistSubmenuOpen(false)} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
); })()} @@ -228,6 +393,17 @@ export default function ContextMenu() { })}> {t('contextMenu.removeFromQueue')}
+
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={() => setPlaylistSubmenuOpen(false)} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
{song.albumId && (
handleAction(() => navigate(`/album/${song.albumId}`))}> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index c57d8021..898bce68 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -8,7 +8,7 @@ import { NavLink } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, - PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags + PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags, ListMusic } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; @@ -22,6 +22,7 @@ const navItems = [ { icon: Tags, labelKey: 'sidebar.genres', to: '/genres' }, { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' }, { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' }, + { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' }, ]; function isNewer(latest: string, current: string): boolean { diff --git a/src/i18n.ts b/src/i18n.ts index f19c8578..cedc516d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -25,6 +25,7 @@ const enTranslation = { downloadingTracks: 'Caching {{n}} tracks…', offlineLibrary: 'Offline Library', genres: 'Genres', + playlists: 'Playlists', }, home: { starred: 'Personal Favorites', @@ -100,6 +101,7 @@ const enTranslation = { openAlbum: 'Open Album', goToArtist: 'Go to Artist', download: 'Download (ZIP)', + addToPlaylist: 'Add to Playlist', }, albumDetail: { back: 'Back', @@ -591,6 +593,32 @@ const enTranslation = { lyrics: 'Lyrics', lyricsLoading: 'Loading lyrics…', lyricsNotFound: 'No lyrics found for this track', + }, + playlists: { + title: 'Playlists', + newPlaylist: 'New Playlist', + unnamed: 'Unnamed Playlist', + createName: 'Playlist name…', + create: 'Create', + cancel: 'Cancel', + empty: 'No playlists yet.', + emptyPlaylist: 'This playlist is empty.', + notFound: 'Playlist not found.', + songs: '{{n}} songs', + playAll: 'Play All', + addToQueue: 'Add to Queue', + back: 'Back to Playlists', + deletePlaylist: 'Delete', + confirmDelete: 'Click again to confirm', + removeSong: 'Remove from playlist', + addSongs: 'Add Songs', + searchPlaceholder: 'Search your library…', + noResults: 'No results.', + suggestions: 'Suggested Songs', + noSuggestions: 'No suggestions available.', + titleBadge: 'Playlist', + refreshSuggestions: 'New suggestions', + addSong: 'Add to playlist', } }; @@ -618,6 +646,7 @@ const deTranslation = { downloadingTracks: '{{n}} Tracks werden gecacht…', offlineLibrary: 'Offline-Bibliothek', genres: 'Genres', + playlists: 'Playlists', }, home: { starred: 'Persönliche Favoriten', @@ -693,6 +722,7 @@ const deTranslation = { openAlbum: 'Album öffnen', goToArtist: 'Zum Künstler', download: 'Herunterladen (ZIP)', + addToPlaylist: 'Zur Playlist hinzufügen', }, albumDetail: { back: 'Zurück', @@ -1184,6 +1214,32 @@ const deTranslation = { lyrics: 'Lyrics', lyricsLoading: 'Lyrics werden geladen…', lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden', + }, + playlists: { + title: 'Playlists', + newPlaylist: 'Neue Playlist', + unnamed: 'Unbenannte Playlist', + createName: 'Playlist-Name…', + create: 'Erstellen', + cancel: 'Abbrechen', + empty: 'Noch keine Playlists.', + emptyPlaylist: 'Diese Playlist ist leer.', + notFound: 'Playlist nicht gefunden.', + songs: '{{n}} Songs', + playAll: 'Alle abspielen', + addToQueue: 'Zur Warteschlange', + back: 'Zurück zu Playlists', + deletePlaylist: 'Löschen', + confirmDelete: 'Nochmals klicken zum Bestätigen', + removeSong: 'Aus Playlist entfernen', + addSongs: 'Songs hinzufügen', + searchPlaceholder: 'Bibliothek durchsuchen…', + noResults: 'Keine Ergebnisse.', + suggestions: 'Vorgeschlagene Songs', + noSuggestions: 'Keine Vorschläge verfügbar.', + titleBadge: 'Playlist', + refreshSuggestions: 'Neue Vorschläge', + addSong: 'Zur Playlist hinzufügen', } }; @@ -1211,6 +1267,7 @@ const frTranslation = { downloadingTracks: '{{n}} pistes en cache…', offlineLibrary: 'Bibliothèque hors ligne', genres: 'Genres', + playlists: 'Playlists', }, home: { starred: 'Favoris personnels', @@ -1286,6 +1343,7 @@ const frTranslation = { openAlbum: 'Ouvrir l\'album', goToArtist: 'Aller à l\'artiste', download: 'Télécharger (ZIP)', + addToPlaylist: 'Ajouter à la playlist', }, albumDetail: { back: 'Retour', @@ -1777,6 +1835,32 @@ const frTranslation = { lyrics: 'Paroles', lyricsLoading: 'Chargement des paroles…', lyricsNotFound: 'Aucune parole trouvée pour ce titre', + }, + playlists: { + title: 'Playlists', + newPlaylist: 'Nouvelle playlist', + unnamed: 'Playlist sans nom', + createName: 'Nom de la playlist…', + create: 'Créer', + cancel: 'Annuler', + empty: 'Aucune playlist pour l\'instant.', + emptyPlaylist: 'Cette playlist est vide.', + notFound: 'Playlist introuvable.', + songs: '{{n}} titres', + playAll: 'Tout lire', + addToQueue: 'Ajouter à la file', + back: 'Retour aux playlists', + deletePlaylist: 'Supprimer', + confirmDelete: 'Cliquer à nouveau pour confirmer', + removeSong: 'Retirer de la playlist', + addSongs: 'Ajouter des titres', + searchPlaceholder: 'Rechercher dans la bibliothèque…', + noResults: 'Aucun résultat.', + suggestions: 'Titres suggérés', + noSuggestions: 'Aucune suggestion disponible.', + titleBadge: 'Playlist', + refreshSuggestions: 'Nouvelles suggestions', + addSong: 'Ajouter à la playlist', } }; @@ -1804,6 +1888,7 @@ const nlTranslation = { downloadingTracks: '{{n}} nummers worden gecached…', offlineLibrary: 'Offline bibliotheek', genres: 'Genres', + playlists: 'Playlists', }, home: { starred: 'Persoonlijke favorieten', @@ -1879,6 +1964,7 @@ const nlTranslation = { openAlbum: 'Album openen', goToArtist: 'Naar artiest', download: 'Downloaden (ZIP)', + addToPlaylist: 'Toevoegen aan playlist', }, albumDetail: { back: 'Terug', @@ -2370,6 +2456,32 @@ const nlTranslation = { lyrics: 'Songtekst', lyricsLoading: 'Songtekst laden…', lyricsNotFound: 'Geen songtekst gevonden voor dit nummer', + }, + playlists: { + title: 'Playlists', + newPlaylist: 'Nieuwe playlist', + unnamed: 'Naamloze playlist', + createName: 'Playlistnaam…', + create: 'Aanmaken', + cancel: 'Annuleren', + empty: 'Nog geen playlists.', + emptyPlaylist: 'Deze playlist is leeg.', + notFound: 'Playlist niet gevonden.', + songs: '{{n}} nummers', + playAll: 'Alles afspelen', + addToQueue: 'Aan wachtrij toevoegen', + back: 'Terug naar playlists', + deletePlaylist: 'Verwijderen', + confirmDelete: 'Nogmaals klikken om te bevestigen', + removeSong: 'Uit playlist verwijderen', + addSongs: 'Nummers toevoegen', + searchPlaceholder: 'Doorzoek bibliotheek…', + noResults: 'Geen resultaten.', + suggestions: 'Aanbevolen nummers', + noSuggestions: 'Geen suggesties beschikbaar.', + titleBadge: 'Playlist', + refreshSuggestions: 'Nieuwe suggesties', + addSong: 'Toevoegen aan playlist', } }; @@ -2397,6 +2509,7 @@ const zhTranslation = { downloadingTracks: '正在缓存 {{n}} 首歌曲…', offlineLibrary: '离线音乐库', genres: '流派', + playlists: '播放列表', }, home: { starred: '个人收藏', @@ -2472,6 +2585,7 @@ const zhTranslation = { openAlbum: '打开专辑', goToArtist: '前往艺术家', download: '下载 (ZIP)', + addToPlaylist: '添加到播放列表', }, albumDetail: { back: '返回', @@ -2963,6 +3077,32 @@ const zhTranslation = { lyrics: '歌词', lyricsLoading: '正在加载歌词…', lyricsNotFound: '未找到此曲目的歌词', + }, + playlists: { + title: '播放列表', + newPlaylist: '新建播放列表', + unnamed: '未命名播放列表', + createName: '播放列表名称…', + create: '创建', + cancel: '取消', + empty: '暂无播放列表。', + emptyPlaylist: '此播放列表为空。', + notFound: '未找到播放列表。', + songs: '{{n}} 首歌曲', + playAll: '全部播放', + addToQueue: '添加到队列', + back: '返回播放列表', + deletePlaylist: '删除', + confirmDelete: '再次点击确认删除', + removeSong: '从播放列表中移除', + addSongs: '添加歌曲', + searchPlaceholder: '搜索音乐库…', + noResults: '无结果。', + suggestions: '推荐歌曲', + noSuggestions: '暂无推荐。', + titleBadge: '播放列表', + refreshSuggestions: '新建议', + addSong: '添加到播放列表', } }; diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx new file mode 100644 index 00000000..95148c05 --- /dev/null +++ b/src/pages/PlaylistDetail.tsx @@ -0,0 +1,614 @@ +import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw } from 'lucide-react'; +import { + getPlaylist, updatePlaylist, search, setRating, star, unstar, + getSimilarSongs2, SubsonicPlaylist, SubsonicSong, +} from '../api/subsonic'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { usePlaylistStore } from '../store/playlistStore'; +import { useDragDrop } from '../contexts/DragDropContext'; +import CachedImage, { useCachedUrl } from '../components/CachedImage'; +import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic'; +import { useTranslation } from 'react-i18next'; + +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +function totalDurationLabel(songs: SubsonicSong[]): string { + const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + +function codecLabel(song: SubsonicSong): string { + const parts: string[] = []; + if (song.suffix) parts.push(song.suffix.toUpperCase()); + if (song.bitRate) parts.push(`${song.bitRate} kbps`); + return parts.join(' · '); +} + +function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) { + const [hover, setHover] = React.useState(0); + return ( +
+ {[1, 2, 3, 4, 5].map(n => ( + + ))} +
+ ); +} + +export default function PlaylistDetail() { + const { id } = useParams<{ id: string }>(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore(); + const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); + const { startDrag, isDragging } = useDragDrop(); + + const [playlist, setPlaylist] = useState(null); + const [songs, setSongs] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [ratings, setRatings] = useState>({}); + const [starredSongs, setStarredSongs] = useState>(new Set()); + const [hoveredSongId, setHoveredSongId] = useState(null); + const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); + const [contextMenuSongId, setContextMenuSongId] = useState(null); + const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); + + // ── 2×2 cover quad (first 4 unique album covers) ───────────── + const coverQuad = useMemo(() => { + const seen = new Set(); + const result: string[] = []; + for (const s of songs) { + if (s.coverArt && !seen.has(s.coverArt)) { + seen.add(s.coverArt); + result.push(s.coverArt); + if (result.length === 4) break; + } + } + return result; + }, [songs]); + + // One resolved URL for the blurred background (must be called unconditionally) + // useMemo is required here — buildCoverArtUrl generates a new salt on every call, + // which would change bgFetchUrl every render and cause useCachedUrl to re-fetch in a loop. + const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]); + const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]); + const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey); + + // Song search + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const searchDebounce = useRef | null>(null); + + // Suggestions + const [suggestions, setSuggestions] = useState([]); + const [loadingSuggestions, setLoadingSuggestions] = useState(false); + + // DnD + const tracklistRef = useRef(null); + const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null); + + useEffect(() => { + if (!contextMenuOpen) setContextMenuSongId(null); + }, [contextMenuOpen]); + + // ── Load ───────────────────────────────────────────────────── + useEffect(() => { + if (!id) return; + setLoading(true); + getPlaylist(id) + .then(({ playlist, songs }) => { + setPlaylist(playlist); + setSongs(songs); + const init: Record = {}; + const starred = new Set(); + songs.forEach(s => { + if (s.userRating) init[s.id] = s.userRating; + if (s.starred) starred.add(s.id); + }); + setRatings(init); + setStarredSongs(starred); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [id]); + + // ── Suggestions ─────────────────────────────────────────────── + const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => { + const withArtist = currentSongs.filter(s => s.artistId); + if (!withArtist.length) return; + const pick = withArtist[Math.floor(Math.random() * withArtist.length)]; + const existingIds = new Set(currentSongs.map(s => s.id)); + setLoadingSuggestions(true); + setSuggestions([]); + try { + const similar = await getSimilarSongs2(pick.artistId!, 25); + setSuggestions(similar.filter(s => !existingIds.has(s.id)).slice(0, 10)); + } catch {} + setLoadingSuggestions(false); + }, []); + + useEffect(() => { + if (songs.length > 0) loadSuggestions(songs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlist?.id]); + + // ── Save ────────────────────────────────────────────────────── + const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => { + if (!id) return; + setSaving(true); + try { + await updatePlaylist(id, updatedSongs.map(s => s.id)); + if (id) touchPlaylist(id); + } catch {} + setSaving(false); + }, [id, touchPlaylist]); + + // ── Remove ──────────────────────────────────────────────────── + const removeSong = (idx: number) => { + const next = songs.filter((_, i) => i !== idx); + setSongs(next); + savePlaylist(next); + }; + + // ── Add ─────────────────────────────────────────────────────── + const addSong = (song: SubsonicSong) => { + if (songs.some(s => s.id === song.id)) return; + const next = [...songs, song]; + setSongs(next); + savePlaylist(next); + setSuggestions(prev => prev.filter(s => s.id !== song.id)); + setSearchResults(prev => prev.filter(s => s.id !== song.id)); + }; + + // ── Rating / Star ───────────────────────────────────────────── + const handleRate = (songId: string, rating: number) => { + setRatings(prev => ({ ...prev, [songId]: rating })); + setRating(songId, rating).catch(() => {}); + }; + + const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => { + e.stopPropagation(); + const isStarred = starredSongs.has(song.id); + setStarredSongs(prev => { + const next = new Set(prev); + isStarred ? next.delete(song.id) : next.add(song.id); + return next; + }); + (isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {}); + }; + + // ── Search ──────────────────────────────────────────────────── + useEffect(() => { + if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; } + if (searchDebounce.current) clearTimeout(searchDebounce.current); + searchDebounce.current = setTimeout(async () => { + setSearching(true); + try { + const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 }); + const existingIds = new Set(songs.map(s => s.id)); + setSearchResults(res.songs.filter(s => !existingIds.has(s.id))); + } catch {} + setSearching(false); + }, 350); + return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); }; + }, [searchQuery, searchOpen, songs]); + + // ── psy-drop DnD reordering ─────────────────────────────────── + useEffect(() => { + const container = tracklistRef.current; + if (!container) return; + + const onPsyDrop = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.data) return; + let parsed: any; + try { parsed = JSON.parse(detail.data); } catch { return; } + if (parsed.type !== 'playlist_reorder') return; + + setDropTargetIdx(null); + + const fromIdx: number = parsed.index; + + // Determine drop index from the event target row + const target = (e.target as HTMLElement).closest('[data-track-idx]'); + let toIdx = songs.length; + if (target) { + const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10); + const rect = target.getBoundingClientRect(); + const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2); + const before = cursorY < rect.top + rect.height / 2; + toIdx = before ? targetIdx : targetIdx + 1; + } + + if (fromIdx === toIdx || fromIdx === toIdx - 1) return; + + setSongs(prev => { + const next = [...prev]; + const [moved] = next.splice(fromIdx, 1); + const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx; + next.splice(insertAt, 0, moved); + savePlaylist(next); + return next; + }); + }; + + container.addEventListener('psy-drop', onPsyDrop); + return () => container.removeEventListener('psy-drop', onPsyDrop); + }, [songs, savePlaylist]); + + // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── + const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest('button, input')) 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); + startDrag( + { data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' }, + me.clientX, me.clientY + ); + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + + // ── Drag-over visual feedback ───────────────────────────────── + const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => { + if (!isDragging) return; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const before = e.clientY < rect.top + rect.height / 2; + setDropTargetIdx({ idx, before }); + }; + + // ── Render ──────────────────────────────────────────────────── + if (loading) { + return ( +
+
+
+ ); + } + + if (!playlist) { + return
{t('playlists.notFound')}
; + } + + const existingIds = new Set(songs.map(s => s.id)); + + return ( +
+ + {/* ── Hero ── */} +
+ {resolvedBgUrl && ( +