feat: v1.26.0 — Bulk Select, Song Info, Favorite Button, Recently Played

### Added
- Favorite/Star button in player bar (requested by @halfkey)
- Bulk multi-select for album tracklist and playlist detail (add to playlist, remove from playlist)
- Song Info modal via right-click context menu (metadata: format, bitrate, sample rate, bit depth, channels, file size, path, replay gain)
- Recently Played section on Home page
- "Show activity in Now Playing" opt-in toggle in Settings → Behavior

### Fixed
- Queue cover art not updating on track change (useCachedUrl/CachedImage reset on cacheKey change)
- FullscreenPlayer background flickering (FsBg preloads image before layer transition)
- Playlist card delete confirmation visual (size expansion + pulse animation)
- Gruvbox Light Soft: back button and badge invisible against light background

### Changed
- buildStreamUrl: removed unused suffix parameter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-01 20:57:28 +02:00
parent 7d1c66071e
commit 434ee0ecf1
26 changed files with 1074 additions and 221 deletions
+23
View File
@@ -5,6 +5,29 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.26.0] - 2026-04-01
### Added
- **Favorite button in Player Bar** *(requested by [@halfkey](https://github.com/halfkey))*: A star icon button now sits next to the Last.fm heart in the player bar. Clicking it toggles the favorite/unfavorite state for the currently playing track with an optimistic UI update — no page reload needed. Uses the same `starredOverrides` mechanism as the album tracklist for instant feedback.
- **Bulk Select for song lists**: Multi-select support in Album tracklist and Playlist detail. A checkbox fades in to the left of the track number on hover. Selecting one or more tracks activates the bulk action bar at the top with two actions: **Add to Playlist** (opens the playlist picker submenu) and **Remove from Playlist** (Playlist detail only). Shift-click selects a range; the header checkbox selects / deselects all. CSS uses `color-mix` for the selection highlight, compatible with all 60 themes.
- **Song Info modal**: Right-clicking any song and choosing "Song Info" opens a metadata panel fetched live via `getSong`. Displays: title, artist, album, album artist, year, genre, duration, track number; format, bitrate, sample rate, bit depth, channels (Mono / Stereo), file size; file path; and Replay Gain values (track / album gain + peak) when present. Closes with Escape or a click on the backdrop.
- **Recently Played section on Home page**: A new "Recently Played" album row appears on the Home page between the hero carousel and the Discover section, powered by the `getAlbumList('recent')` endpoint.
- **"Now Playing" visibility toggle in Settings**: New opt-in toggle in Settings → Behavior ("Show activity in Now Playing"). When disabled (default), `reportNowPlaying` is not called, so no activity is reported to the Navidrome "Now Playing" feed. Useful for users who share a server.
### Fixed
- **Queue cover art not updating**: After a track change the queue panel cover art often stayed on the previous album or took a long time to update. Root cause: `useCachedUrl` and `CachedImage` were not resetting their resolved URL when the `cacheKey` changed. Fixed by resetting `resolved` to `''` before each async cache fetch and basing `CachedImage`'s `loaded` state on `useEffect([cacheKey])` instead of a render-time comparison.
- **Fullscreen Player background flickering**: The blurred background briefly showed a blank frame when switching tracks because the new image div was added to the DOM before the blob URL was ready. Fixed in `FsBg` by preloading the image via `new Image()` before inserting the layer, and using `useCachedUrl(..., false)` for the crossfade background so the raw URL is never used as a fallback during transitions.
- **Playlist card delete confirmation not visible**: The confirm state only changed the icon colour, which was barely noticeable over the red button. Replaced with a size expansion (24 px → 30 px), an inset white ring, and a pulsing `delete-confirm-pulse` animation that alternates between two shades of red.
- **Gruvbox Light Soft — back button and badge**: The album detail back-arrow and album badge were invisible against the warm light background. Added explicit colour overrides for `.album-detail-back` and `.album-detail-badge` in the gruvbox-light-soft theme.
### Changed
- **`buildStreamUrl` signature**: Removed the unused `suffix` parameter. Opus transcoding (`format=flac`) is now handled in `playerStore.playTrack` via `track.suffix` check, keeping the URL builder stateless.
---
## [1.25.1] - 2026-04-01 ## [1.25.1] - 2026-04-01
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.25.1", "version": "1.26.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de> # Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic pkgname=psysonic
pkgver=1.25.1 pkgver=1.26.0
pkgrel=1 pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -3397,7 +3397,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.25.0" version = "1.25.1"
dependencies = [ dependencies = [
"biquad", "biquad",
"md5", "md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.25.1" version = "1.26.0"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.25.1", "version": "1.26.0",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+2
View File
@@ -31,6 +31,7 @@ import PlaylistDetail from './pages/PlaylistDetail';
import NowPlayingPage from './pages/NowPlaying'; import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer'; import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu'; import ContextMenu from './components/ContextMenu';
import SongInfoModal from './components/SongInfoModal';
import DownloadFolderModal from './components/DownloadFolderModal'; import DownloadFolderModal from './components/DownloadFolderModal';
import { DragDropProvider } from './contexts/DragDropContext'; import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal'; import TooltipPortal from './components/TooltipPortal';
@@ -272,6 +273,7 @@ function AppShell() {
<FullscreenPlayer onClose={toggleFullscreen} /> <FullscreenPlayer onClose={toggleFullscreen} />
)} )}
<ContextMenu /> <ContextMenu />
<SongInfoModal />
<DownloadFolderModal /> <DownloadFolderModal />
<TooltipPortal /> <TooltipPortal />
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />} {changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
+2 -5
View File
@@ -74,6 +74,7 @@ export interface SubsonicSong {
contentType?: string; contentType?: string;
size?: number; size?: number;
samplingRate?: number; samplingRate?: number;
bitDepth?: number;
channelCount?: number; channelCount?: number;
starred?: string; starred?: string;
genre?: string; genre?: string;
@@ -325,7 +326,7 @@ export async function reportNowPlaying(id: string): Promise<void> {
} }
// ─── Stream URL ─────────────────────────────────────────────── // ─── Stream URL ───────────────────────────────────────────────
export function buildStreamUrl(id: string, suffix?: string): string { export function buildStreamUrl(id: string): string {
const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer(); const server = getActiveServer();
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
@@ -337,10 +338,6 @@ export function buildStreamUrl(id: string, suffix?: string): string {
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json', t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
}); });
if (suffix === 'opus') {
p.set('format', 'flac'); // Transcode OPUS to FLAC since Rust/Symphonia has no Opus decoder
}
return `${baseUrl}/rest/stream.view?${p.toString()}`; return `${baseUrl}/rest/stream.view?${p.toString()}`;
} }
+106 -12
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Play, Star } from 'lucide-react'; import { Play, Star, ListPlus, X } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic'; import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
@@ -71,9 +72,45 @@ export default function AlbumTrackList({
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null); const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
// ── Bulk select ───────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdx !== null) {
const from = Math.min(lastSelectedIdx, globalIdx);
const to = Math.max(lastSelectedIdx, globalIdx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
next.has(id) ? next.delete(id) : next.add(id);
}
return next;
});
setLastSelectedIdx(globalIdx);
};
const allSelected = selectedIds.size === songs.length && songs.length > 0;
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
useEffect(() => { useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null); if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]); }, [contextMenuOpen]);
// Close playlist picker on outside click
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0); const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>(); const discs = new Map<number, SubsonicSong[]>();
@@ -85,10 +122,49 @@ export default function AlbumTrackList({
const discNums = Array.from(discs.keys()).sort((a, b) => a - b); const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = discNums.length > 1; const isMultiDisc = discNums.length > 1;
const inSelectMode = selectedIds.size > 0;
return ( return (
<div className="tracklist"> <div className="tracklist">
{/* ── Bulk action bar ── */}
{inSelectMode && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...selectedIds]}
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
<div className={`tracklist-header${' tracklist-va'}`}> <div className={`tracklist-header${' tracklist-va'}`}>
<div className="col-center">#</div> <div className="col-center">
{inSelectMode
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
: '#'}
</div>
<div>{t('albumDetail.trackTitle')}</div> <div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div> <div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div> <div className="col-center">{t('albumDetail.trackFavorite')}</div>
@@ -105,13 +181,20 @@ export default function AlbumTrackList({
CD {discNum} CD {discNum}
</div> </div>
)} )}
{discs.get(discNum)!.map((song, i) => ( {discs.get(discNum)!.map((song) => {
const globalIdx = songs.indexOf(song);
return (
<div <div
key={song.id} key={song.id}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`} className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)} onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)} onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => onPlaySong(song)} onDoubleClick={() => onPlaySong(song)}
onClick={e => {
if (inSelectMode && !(e.target as HTMLElement).closest('button, input')) {
toggleSelect(song.id, globalIdx, e.shiftKey);
}
}}
onContextMenu={e => { onContextMenu={e => {
e.preventDefault(); e.preventDefault();
setContextMenuSongId(song.id); setContextMenuSongId(song.id);
@@ -136,19 +219,29 @@ export default function AlbumTrackList({
> >
<div <div
className="track-num" className="track-num"
style={{ style={{ cursor: 'pointer' }}
cursor: hoveredSongId === song.id ? 'pointer' : 'default', onClick={e => {
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined, e.stopPropagation();
if (inSelectMode || hoveredSongId === song.id) {
toggleSelect(song.id, globalIdx, e.shiftKey);
} else {
onPlaySong(song);
}
}} }}
onClick={() => onPlaySong(song)}
> >
{hoveredSongId === song.id && currentTrack?.id !== song.id <span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
<span style={{ color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
? <Play size={13} fill="currentColor" /> ? <Play size={13} fill="currentColor" />
: currentTrack?.id === song.id && isPlaying : currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div> ? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: currentTrack?.id === song.id : currentTrack?.id === song.id
? <Play size={13} fill="currentColor" /> ? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)} : (song.track ?? globalIdx + 1)}
</span>
</div> </div>
<div className="track-info"> <div className="track-info">
<span className="track-title">{song.title}</span> <span className="track-title">{song.title}</span>
@@ -179,7 +272,8 @@ export default function AlbumTrackList({
)} )}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
))} ))}
+15 -8
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache'; import { getCachedUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -6,31 +6,38 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
cacheKey: string; cacheKey: string;
} }
export function useCachedUrl(fetchUrl: string, cacheKey: string): string { /**
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
* blob is still resolving useful for <img> tags so the browser starts
* loading immediately. Pass false for CSS background-image consumers that
* should only see a stable blob URL (prevents a double crossfade).
*/
export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string {
const [resolved, setResolved] = useState(''); const [resolved, setResolved] = useState('');
useEffect(() => { useEffect(() => {
if (!fetchUrl) { setResolved(''); return; } if (!fetchUrl) { setResolved(''); return; }
let cancelled = false; let cancelled = false;
setResolved('');
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); }); getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [fetchUrl, cacheKey]); }, [fetchUrl, cacheKey]);
return resolved || fetchUrl; return fallbackToFetch ? (resolved || fetchUrl) : resolved;
} }
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) { export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
const resolvedSrc = useCachedUrl(src, cacheKey); const resolvedSrc = useCachedUrl(src, cacheKey);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const prevSrc = useRef('');
if (resolvedSrc !== prevSrc.current) { // Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
prevSrc.current = resolvedSrc; // URL upgrades within the same image — avoids the end-of-load flash.
useEffect(() => {
setLoaded(false); setLoaded(false);
} }, [cacheKey]);
return ( return (
<img <img
src={resolvedSrc} src={resolvedSrc}
style={{ ...style, opacity: loaded ? 1 : 0, transition: loaded ? 'opacity 0.15s ease' : 'none' }} style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
onLoad={e => { setLoaded(true); onLoad?.(e); }} onLoad={e => { setLoaded(true); onLoad?.(e); }}
{...props} {...props}
/> />
+14 -4
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from '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 { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart, ListMusic, Plus, Info } from 'lucide-react';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
@@ -21,7 +21,7 @@ function sanitizeFilename(name: string): string {
} }
// ── Add-to-Playlist submenu ─────────────────────────────────────── // ── Add-to-Playlist submenu ───────────────────────────────────────
function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) { export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null); const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null); const newNameRef = useRef<HTMLInputElement>(null);
@@ -84,7 +84,9 @@ function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone:
onDone(); onDone();
}; };
const subStyle: React.CSSProperties = flipLeft const subStyle: React.CSSProperties = dropDown
? { top: 'calc(100% + 4px)', left: 0, right: 'auto' }
: flipLeft
? { right: 'calc(100% + 4px)', left: 'auto' } ? { right: 'calc(100% + 4px)', left: 'auto' }
: { left: 'calc(100% + 4px)', right: 'auto' }; : { left: 'calc(100% + 4px)', right: 'auto' };
@@ -160,7 +162,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
export default function ContextMenu() { export default function ContextMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore(); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
const auth = useAuthStore(); const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -320,6 +322,10 @@ export default function ContextMenu() {
</div> </div>
); );
})()} })()}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</> </>
); );
})()} })()}
@@ -436,6 +442,10 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}> <div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')} <Radio size={14} /> {t('contextMenu.startRadio')}
</div> </div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</> </>
); );
})()} })()}
+18 -8
View File
@@ -62,16 +62,25 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
useEffect(() => { useEffect(() => {
if (!url) return; if (!url) return;
let cancelled = false;
const id = counterRef.current++; const id = counterRef.current++;
// Preload the image before starting the crossfade — prevents a blank flash
// between the old and new layer while the browser decodes the image.
const img = new Image();
img.onload = img.onerror = () => {
if (cancelled) return;
setLayers(prev => [...prev, { url, id, visible: false }]); setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => { requestAnimationFrame(() => {
if (cancelled) return;
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
}, 20); setTimeout(() => {
const t2 = setTimeout(() => { if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
setLayers(prev => prev.filter(l => l.id === id));
}, 800); }, 800);
return () => { clearTimeout(t1); clearTimeout(t2); }; });
}, [url]); // eslint-disable-line react-hooks/exhaustive-deps };
img.src = url;
return () => { cancelled = true; };
}, [url]);
return ( return (
<> <>
@@ -158,8 +167,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
// to prevent useCachedUrl from re-fetching on every progress re-render (100 ms). // to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]); const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]); const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
// useCachedUrl must be called unconditionally (hook rules) // No fetchUrl fallback for the background — we only want stable blob URLs
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); // to avoid a double crossfade (fetchUrl → blobUrl for the same image).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Fetch artist image for background — fall back to cover art if unavailable // Fetch artist image for background — fall back to cover art if unavailable
const [artistBgUrl, setArtistBgUrl] = useState<string>(''); const [artistBgUrl, setArtistBgUrl] = useState<string>('');
+30 -2
View File
@@ -2,11 +2,11 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, Star, MicVocal
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import CachedImage from './CachedImage'; import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek'; import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer'; import Equalizer from './Equalizer';
@@ -35,9 +35,26 @@ export default function PlayerBar() {
stop, toggleRepeat, repeatMode, toggleFullscreen, stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove, lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue, isQueueVisible, toggleQueue,
starredOverrides, setStarredOverride,
} = usePlayerStore(); } = usePlayerStore();
const { lastfmSessionKey } = useAuthStore(); const { lastfmSessionKey } = useAuthStore();
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const next = !isStarred;
setStarredOverride(currentTrack.id, next);
try {
if (next) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !next);
}
}, [currentTrack, isStarred, setStarredOverride]);
const duration = currentTrack?.duration ?? 0; const duration = currentTrack?.duration ?? 0;
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]); const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : ''; const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
@@ -92,6 +109,17 @@ export default function PlayerBar() {
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
/> />
</div> </div>
{currentTrack && (
<button
className="player-btn player-btn-sm player-star-btn"
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
style={{ color: isStarred ? 'var(--ctp-yellow)' : 'var(--text-muted)', flexShrink: 0 }}
>
<Star size={15} fill={isStarred ? 'var(--ctp-yellow)' : 'none'} />
</button>
)}
{currentTrack && lastfmSessionKey && ( {currentTrack && lastfmSessionKey && (
<button <button
className="player-btn player-btn-sm player-love-btn" className="player-btn player-btn-sm player-love-btn"
+17 -3
View File
@@ -1,7 +1,8 @@
import React, { useState, useRef, useMemo } from 'react'; import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -159,10 +160,15 @@ export default function QueuePanel() {
const queueIndex = usePlayerStore(s => s.queueIndex); const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const currentTime = usePlayerStore(s => s.currentTime); const currentTime = usePlayerStore(s => s.currentTime);
const currentCoverSrc = useMemo( const currentCoverFetchUrl = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', () => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
[currentTrack?.coverArt] [currentTrack?.coverArt]
); );
const currentCoverCacheKey = useMemo(
() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '',
[currentTrack?.coverArt]
);
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const playTrack = usePlayerStore(s => s.playTrack); const playTrack = usePlayerStore(s => s.playTrack);
const toggleQueue = usePlayerStore(s => s.toggleQueue); const toggleQueue = usePlayerStore(s => s.toggleQueue);
@@ -363,12 +369,20 @@ export default function QueuePanel() {
{currentTrack && ( {currentTrack && (
<div className="queue-current-track"> <div className="queue-current-track">
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate) && ( {(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
<div className="queue-current-tech"> <div className="queue-current-tech">
{[ {[
currentTrack.genre, currentTrack.genre,
currentTrack.suffix?.toUpperCase(), currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined, currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
(() => {
const bd = currentTrack.bitDepth;
const sr = currentTrack.samplingRate ? `${currentTrack.samplingRate / 1000} kHz` : '';
if (bd && sr) return `${bd}/${sr}`;
if (bd) return `${bd}-bit`;
if (sr) return sr;
return undefined;
})(),
].filter(Boolean).join(' · ')} ].filter(Boolean).join(' · ')}
</div> </div>
)} )}
+145
View File
@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { getSong, SubsonicSong } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
function formatDuration(s: number): string {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, '0')}`;
}
function formatSize(bytes?: number): string | null {
if (!bytes) return null;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
return `${(bytes / 1_000).toFixed(0)} KB`;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === '' || value === '—') return null;
return (
<tr>
<td className="song-info-label">{label}</td>
<td className="song-info-value">{value}</td>
</tr>
);
}
function Divider() {
return <tr><td colSpan={2} className="song-info-divider" /></tr>;
}
export default function SongInfoModal() {
const { t } = useTranslation();
const { songInfoModal, closeSongInfo } = usePlayerStore();
const [song, setSong] = useState<SubsonicSong | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!songInfoModal.isOpen || !songInfoModal.songId) {
setSong(null);
return;
}
let cancelled = false;
setLoading(true);
getSong(songInfoModal.songId).then(s => {
if (!cancelled) { setSong(s); setLoading(false); }
});
return () => { cancelled = true; };
}, [songInfoModal.isOpen, songInfoModal.songId]);
useEffect(() => {
if (!songInfoModal.isOpen) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeSongInfo(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [songInfoModal.isOpen, closeSongInfo]);
if (!songInfoModal.isOpen) return null;
const channels = song?.channelCount === 1
? t('songInfo.mono')
: song?.channelCount === 2
? t('songInfo.stereo')
: song?.channelCount
? `${song.channelCount} ch`
: null;
const trackLabel = song?.discNumber && song.discNumber > 1
? `${song.discNumber} ${song.track}`
: song?.track != null
? String(song.track)
: null;
const hasReplayGain = song?.replayGain &&
(song.replayGain.trackGain !== undefined || song.replayGain.albumGain !== undefined);
return createPortal(
<>
<div className="song-info-backdrop" onClick={closeSongInfo} />
<div className="song-info-modal" role="dialog" aria-modal="true" aria-label={t('songInfo.title')}>
<div className="song-info-header">
<span className="song-info-title">{t('songInfo.title')}</span>
<button className="btn btn-ghost song-info-close" onClick={closeSongInfo} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="song-info-body">
{loading && <div className="song-info-loading">{t('common.loading')}</div>}
{!loading && song && (
<table className="song-info-table">
<tbody>
<Row label={t('songInfo.songTitle')} value={song.title} />
<Row label={t('songInfo.artist')} value={song.artist} />
<Row label={t('songInfo.album')} value={song.album} />
{song.albumArtist && song.albumArtist !== song.artist && (
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
)}
<Row label={t('songInfo.year')} value={song.year} />
<Row label={t('songInfo.genre')} value={song.genre} />
<Row label={t('songInfo.duration')} value={formatDuration(song.duration)} />
<Row label={t('songInfo.track')} value={trackLabel} />
<Divider />
<Row label={t('songInfo.format')} value={[song.suffix?.toUpperCase(), song.contentType].filter(Boolean).join(' · ') || null} />
<Row label={t('songInfo.bitrate')} value={song.bitRate ? `${song.bitRate} kbps` : null} />
<Row label={t('songInfo.sampleRate')} value={song.samplingRate ? `${(song.samplingRate / 1000).toFixed(1)} kHz` : null} />
<Row label={t('songInfo.bitDepth')} value={song.bitDepth ? `${song.bitDepth} bit` : null} />
<Row label={t('songInfo.channels')} value={channels} />
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
{song.path && (
<>
<Divider />
<Row label={t('songInfo.path')} value={<span className="song-info-path">{song.path}</span>} />
</>
)}
{hasReplayGain && (
<>
<Divider />
{song.replayGain!.trackGain !== undefined && (
<Row label={t('songInfo.replayGainTrack')} value={`${song.replayGain!.trackGain >= 0 ? '+' : ''}${song.replayGain!.trackGain.toFixed(2)} dB`} />
)}
{song.replayGain!.albumGain !== undefined && (
<Row label={t('songInfo.replayGainAlbum')} value={`${song.replayGain!.albumGain >= 0 ? '+' : ''}${song.replayGain!.albumGain.toFixed(2)} dB`} />
)}
{song.replayGain!.trackPeak !== undefined && (
<Row label={t('songInfo.replayGainPeak')} value={song.replayGain!.trackPeak.toFixed(6)} />
)}
</>
)}
</tbody>
</table>
)}
</div>
</div>
</>,
document.body
);
}
+160
View File
@@ -31,6 +31,7 @@ const enTranslation = {
starred: 'Personal Favorites', starred: 'Personal Favorites',
recent: 'Recently Added', recent: 'Recently Added',
mostPlayed: 'Most Played', mostPlayed: 'Most Played',
recentlyPlayed: 'Recently Played',
discover: 'Discover', discover: 'Discover',
loadMore: 'Load More', loadMore: 'Load More',
discoverMore: 'Discover More', discoverMore: 'Discover More',
@@ -102,6 +103,7 @@ const enTranslation = {
goToArtist: 'Go to Artist', goToArtist: 'Go to Artist',
download: 'Download (ZIP)', download: 'Download (ZIP)',
addToPlaylist: 'Add to Playlist', addToPlaylist: 'Add to Playlist',
songInfo: 'Song Info',
}, },
albumDetail: { albumDetail: {
back: 'Back', back: 'Back',
@@ -300,6 +302,10 @@ const enTranslation = {
filterSearchGenres: 'Search genres…', filterSearchGenres: 'Search genres…',
filterNoGenres: 'No genres match', filterNoGenres: 'No genres match',
filterClear: 'Clear', filterClear: 'Clear',
bulkSelected: '{{count}} selected',
bulkAddToPlaylist: 'Add to Playlist',
bulkRemoveFromPlaylist: 'Remove from Playlist',
bulkClear: 'Clear selection',
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',
@@ -363,6 +369,8 @@ const enTranslation = {
cacheClearCancel: 'Cancel', cacheClearCancel: 'Cancel',
minimizeToTray: 'Minimize to Tray', minimizeToTray: 'Minimize to Tray',
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.', minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
nowPlayingEnabled: 'Show in Now Playing',
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
downloadsTitle: 'Download Folder', downloadsTitle: 'Download Folder',
downloadsDefault: 'Default Downloads Folder', downloadsDefault: 'Default Downloads Folder',
pickFolder: 'Select', pickFolder: 'Select',
@@ -600,6 +608,29 @@ const enTranslation = {
lyricsLoading: 'Loading lyrics…', lyricsLoading: 'Loading lyrics…',
lyricsNotFound: 'No lyrics found for this track', lyricsNotFound: 'No lyrics found for this track',
}, },
songInfo: {
title: 'Song Info',
songTitle: 'Title',
artist: 'Artist',
album: 'Album',
albumArtist: 'Album Artist',
year: 'Year',
genre: 'Genre',
duration: 'Duration',
track: 'Track',
format: 'Format',
bitrate: 'Bitrate',
sampleRate: 'Sample Rate',
bitDepth: 'Bit Depth',
channels: 'Channels',
fileSize: 'File Size',
path: 'Path',
replayGainTrack: 'RG Track Gain',
replayGainAlbum: 'RG Album Gain',
replayGainPeak: 'RG Track Peak',
mono: 'Mono',
stereo: 'Stereo',
},
playlists: { playlists: {
title: 'Playlists', title: 'Playlists',
newPlaylist: 'New Playlist', newPlaylist: 'New Playlist',
@@ -612,6 +643,7 @@ const enTranslation = {
notFound: 'Playlist not found.', notFound: 'Playlist not found.',
songs: '{{n}} songs', songs: '{{n}} songs',
playAll: 'Play All', playAll: 'Play All',
shuffle: 'Shuffle',
addToQueue: 'Add to Queue', addToQueue: 'Add to Queue',
back: 'Back to Playlists', back: 'Back to Playlists',
deletePlaylist: 'Delete', deletePlaylist: 'Delete',
@@ -658,6 +690,7 @@ const deTranslation = {
starred: 'Persönliche Favoriten', starred: 'Persönliche Favoriten',
recent: 'Zuletzt hinzugefügt', recent: 'Zuletzt hinzugefügt',
mostPlayed: 'Meistgehört', mostPlayed: 'Meistgehört',
recentlyPlayed: 'Kürzlich gespielt',
discover: 'Entdecken', discover: 'Entdecken',
loadMore: 'Mehr laden', loadMore: 'Mehr laden',
discoverMore: 'Mehr entdecken', discoverMore: 'Mehr entdecken',
@@ -729,6 +762,7 @@ const deTranslation = {
goToArtist: 'Zum Künstler', goToArtist: 'Zum Künstler',
download: 'Herunterladen (ZIP)', download: 'Herunterladen (ZIP)',
addToPlaylist: 'Zur Playlist hinzufügen', addToPlaylist: 'Zur Playlist hinzufügen',
songInfo: 'Song-Infos',
}, },
albumDetail: { albumDetail: {
back: 'Zurück', back: 'Zurück',
@@ -927,6 +961,10 @@ const deTranslation = {
filterSearchGenres: 'Genres suchen…', filterSearchGenres: 'Genres suchen…',
filterNoGenres: 'Keine Genres gefunden', filterNoGenres: 'Keine Genres gefunden',
filterClear: 'Zurücksetzen', filterClear: 'Zurücksetzen',
bulkSelected: '{{count}} ausgewählt',
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
bulkClear: 'Auswahl aufheben',
}, },
settings: { settings: {
title: 'Einstellungen', title: 'Einstellungen',
@@ -990,6 +1028,8 @@ const deTranslation = {
cacheClearCancel: 'Abbrechen', cacheClearCancel: 'Abbrechen',
minimizeToTray: 'Im Tray minimieren', minimizeToTray: 'Im Tray minimieren',
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.', minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
nowPlayingEnabled: 'Im Livefenster anzeigen',
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
downloadsTitle: 'Download-Ordner', downloadsTitle: 'Download-Ordner',
downloadsDefault: 'Standard-Downloads-Ordner', downloadsDefault: 'Standard-Downloads-Ordner',
pickFolder: 'Auswählen', pickFolder: 'Auswählen',
@@ -1227,6 +1267,29 @@ const deTranslation = {
lyricsLoading: 'Lyrics werden geladen…', lyricsLoading: 'Lyrics werden geladen…',
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden', lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
}, },
songInfo: {
title: 'Song-Infos',
songTitle: 'Titel',
artist: 'Künstler',
album: 'Album',
albumArtist: 'Album-Künstler',
year: 'Jahr',
genre: 'Genre',
duration: 'Länge',
track: 'Track',
format: 'Format',
bitrate: 'Bitrate',
sampleRate: 'Abtastrate',
bitDepth: 'Bittiefe',
channels: 'Kanäle',
fileSize: 'Dateigröße',
path: 'Speicherort',
replayGainTrack: 'RG Track Gain',
replayGainAlbum: 'RG Album Gain',
replayGainPeak: 'RG Track Peak',
mono: 'Mono',
stereo: 'Stereo',
},
playlists: { playlists: {
title: 'Playlists', title: 'Playlists',
newPlaylist: 'Neue Playlist', newPlaylist: 'Neue Playlist',
@@ -1239,6 +1302,7 @@ const deTranslation = {
notFound: 'Playlist nicht gefunden.', notFound: 'Playlist nicht gefunden.',
songs: '{{n}} Songs', songs: '{{n}} Songs',
playAll: 'Alle abspielen', playAll: 'Alle abspielen',
shuffle: 'Zufallswiedergabe',
addToQueue: 'Zur Warteschlange', addToQueue: 'Zur Warteschlange',
back: 'Zurück zu Playlists', back: 'Zurück zu Playlists',
deletePlaylist: 'Löschen', deletePlaylist: 'Löschen',
@@ -1285,6 +1349,7 @@ const frTranslation = {
starred: 'Favoris personnels', starred: 'Favoris personnels',
recent: 'Ajoutés récemment', recent: 'Ajoutés récemment',
mostPlayed: 'Les plus écoutés', mostPlayed: 'Les plus écoutés',
recentlyPlayed: 'Récemment écoutés',
discover: 'Découvrir', discover: 'Découvrir',
loadMore: 'Charger plus', loadMore: 'Charger plus',
discoverMore: 'Découvrir plus', discoverMore: 'Découvrir plus',
@@ -1356,6 +1421,7 @@ const frTranslation = {
goToArtist: 'Aller à l\'artiste', goToArtist: 'Aller à l\'artiste',
download: 'Télécharger (ZIP)', download: 'Télécharger (ZIP)',
addToPlaylist: 'Ajouter à la playlist', addToPlaylist: 'Ajouter à la playlist',
songInfo: 'Infos du morceau',
}, },
albumDetail: { albumDetail: {
back: 'Retour', back: 'Retour',
@@ -1554,6 +1620,10 @@ const frTranslation = {
filterSearchGenres: 'Rechercher des genres…', filterSearchGenres: 'Rechercher des genres…',
filterNoGenres: 'Aucun genre trouvé', filterNoGenres: 'Aucun genre trouvé',
filterClear: 'Effacer', filterClear: 'Effacer',
bulkSelected: '{{count}} sélectionné(s)',
bulkAddToPlaylist: 'Ajouter à la playlist',
bulkRemoveFromPlaylist: 'Retirer de la playlist',
bulkClear: 'Désélectionner',
}, },
settings: { settings: {
title: 'Paramètres', title: 'Paramètres',
@@ -1617,6 +1687,8 @@ const frTranslation = {
cacheClearCancel: 'Annuler', cacheClearCancel: 'Annuler',
minimizeToTray: 'Réduire dans la barre système', minimizeToTray: 'Réduire dans la barre système',
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.', minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
nowPlayingEnabled: 'Afficher dans la fenêtre live',
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
downloadsTitle: 'Dossier de téléchargement', downloadsTitle: 'Dossier de téléchargement',
downloadsDefault: 'Dossier de téléchargement par défaut', downloadsDefault: 'Dossier de téléchargement par défaut',
pickFolder: 'Sélectionner', pickFolder: 'Sélectionner',
@@ -1854,6 +1926,29 @@ const frTranslation = {
lyricsLoading: 'Chargement des paroles…', lyricsLoading: 'Chargement des paroles…',
lyricsNotFound: 'Aucune parole trouvée pour ce titre', lyricsNotFound: 'Aucune parole trouvée pour ce titre',
}, },
songInfo: {
title: 'Infos du morceau',
songTitle: 'Titre',
artist: 'Artiste',
album: 'Album',
albumArtist: 'Artiste de l\'album',
year: 'Année',
genre: 'Genre',
duration: 'Durée',
track: 'Piste',
format: 'Format',
bitrate: 'Débit',
sampleRate: 'Fréquence d\'échantillonnage',
bitDepth: 'Profondeur de bits',
channels: 'Canaux',
fileSize: 'Taille du fichier',
path: 'Emplacement',
replayGainTrack: 'RG Gain de piste',
replayGainAlbum: 'RG Gain d\'album',
replayGainPeak: 'RG Crête de piste',
mono: 'Mono',
stereo: 'Stéréo',
},
playlists: { playlists: {
title: 'Playlists', title: 'Playlists',
newPlaylist: 'Nouvelle playlist', newPlaylist: 'Nouvelle playlist',
@@ -1866,6 +1961,7 @@ const frTranslation = {
notFound: 'Playlist introuvable.', notFound: 'Playlist introuvable.',
songs: '{{n}} titres', songs: '{{n}} titres',
playAll: 'Tout lire', playAll: 'Tout lire',
shuffle: 'Aléatoire',
addToQueue: 'Ajouter à la file', addToQueue: 'Ajouter à la file',
back: 'Retour aux playlists', back: 'Retour aux playlists',
deletePlaylist: 'Supprimer', deletePlaylist: 'Supprimer',
@@ -1912,6 +2008,7 @@ const nlTranslation = {
starred: 'Persoonlijke favorieten', starred: 'Persoonlijke favorieten',
recent: 'Recent toegevoegd', recent: 'Recent toegevoegd',
mostPlayed: 'Meest gespeeld', mostPlayed: 'Meest gespeeld',
recentlyPlayed: 'Recent afgespeeld',
discover: 'Ontdekken', discover: 'Ontdekken',
loadMore: 'Meer laden', loadMore: 'Meer laden',
discoverMore: 'Meer ontdekken', discoverMore: 'Meer ontdekken',
@@ -1983,6 +2080,7 @@ const nlTranslation = {
goToArtist: 'Naar artiest', goToArtist: 'Naar artiest',
download: 'Downloaden (ZIP)', download: 'Downloaden (ZIP)',
addToPlaylist: 'Toevoegen aan playlist', addToPlaylist: 'Toevoegen aan playlist',
songInfo: 'Nummerinfo',
}, },
albumDetail: { albumDetail: {
back: 'Terug', back: 'Terug',
@@ -2181,6 +2279,10 @@ const nlTranslation = {
filterSearchGenres: 'Genres zoeken…', filterSearchGenres: 'Genres zoeken…',
filterNoGenres: 'Geen genres gevonden', filterNoGenres: 'Geen genres gevonden',
filterClear: 'Wissen', filterClear: 'Wissen',
bulkSelected: '{{count}} geselecteerd',
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
bulkClear: 'Selectie wissen',
}, },
settings: { settings: {
title: 'Instellingen', title: 'Instellingen',
@@ -2244,6 +2346,8 @@ const nlTranslation = {
cacheClearCancel: 'Annuleren', cacheClearCancel: 'Annuleren',
minimizeToTray: 'Minimaliseren naar systeemvak', minimizeToTray: 'Minimaliseren naar systeemvak',
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.', minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
nowPlayingEnabled: 'Weergeven in live-venster',
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
downloadsTitle: 'Downloadmap', downloadsTitle: 'Downloadmap',
downloadsDefault: 'Standaard downloadmap', downloadsDefault: 'Standaard downloadmap',
pickFolder: 'Selecteren', pickFolder: 'Selecteren',
@@ -2481,6 +2585,29 @@ const nlTranslation = {
lyricsLoading: 'Songtekst laden…', lyricsLoading: 'Songtekst laden…',
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer', lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
}, },
songInfo: {
title: 'Nummerinfo',
songTitle: 'Titel',
artist: 'Artiest',
album: 'Album',
albumArtist: 'Albumartiest',
year: 'Jaar',
genre: 'Genre',
duration: 'Duur',
track: 'Track',
format: 'Formaat',
bitrate: 'Bitrate',
sampleRate: 'Samplefrequentie',
bitDepth: 'Bitdiepte',
channels: 'Kanalen',
fileSize: 'Bestandsgrootte',
path: 'Locatie',
replayGainTrack: 'RG Track Gain',
replayGainAlbum: 'RG Album Gain',
replayGainPeak: 'RG Track Peak',
mono: 'Mono',
stereo: 'Stereo',
},
playlists: { playlists: {
title: 'Playlists', title: 'Playlists',
newPlaylist: 'Nieuwe playlist', newPlaylist: 'Nieuwe playlist',
@@ -2493,6 +2620,7 @@ const nlTranslation = {
notFound: 'Playlist niet gevonden.', notFound: 'Playlist niet gevonden.',
songs: '{{n}} nummers', songs: '{{n}} nummers',
playAll: 'Alles afspelen', playAll: 'Alles afspelen',
shuffle: 'Willekeurig',
addToQueue: 'Aan wachtrij toevoegen', addToQueue: 'Aan wachtrij toevoegen',
back: 'Terug naar playlists', back: 'Terug naar playlists',
deletePlaylist: 'Verwijderen', deletePlaylist: 'Verwijderen',
@@ -2539,6 +2667,7 @@ const zhTranslation = {
starred: '个人收藏', starred: '个人收藏',
recent: '最近添加', recent: '最近添加',
mostPlayed: '最常播放', mostPlayed: '最常播放',
recentlyPlayed: '最近播放',
discover: '发现', discover: '发现',
loadMore: '加载更多', loadMore: '加载更多',
discoverMore: '发现更多', discoverMore: '发现更多',
@@ -2610,6 +2739,7 @@ const zhTranslation = {
goToArtist: '前往艺术家', goToArtist: '前往艺术家',
download: '下载 (ZIP)', download: '下载 (ZIP)',
addToPlaylist: '添加到播放列表', addToPlaylist: '添加到播放列表',
songInfo: '歌曲信息',
}, },
albumDetail: { albumDetail: {
back: '返回', back: '返回',
@@ -2808,6 +2938,10 @@ const zhTranslation = {
filterSearchGenres: '搜索流派…', filterSearchGenres: '搜索流派…',
filterNoGenres: '未找到匹配流派', filterNoGenres: '未找到匹配流派',
filterClear: '清除', filterClear: '清除',
bulkSelected: '已选 {{count}} 首',
bulkAddToPlaylist: '添加到播放列表',
bulkRemoveFromPlaylist: '从播放列表移除',
bulkClear: '取消选择',
}, },
settings: { settings: {
title: '设置', title: '设置',
@@ -2871,6 +3005,8 @@ const zhTranslation = {
cacheClearCancel: '取消', cacheClearCancel: '取消',
minimizeToTray: '最小化到托盘', minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。', minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
nowPlayingEnabled: '在实时窗口中显示',
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
downloadsTitle: '下载文件夹', downloadsTitle: '下载文件夹',
downloadsDefault: '默认下载文件夹', downloadsDefault: '默认下载文件夹',
pickFolder: '选择', pickFolder: '选择',
@@ -3108,6 +3244,29 @@ const zhTranslation = {
lyricsLoading: '正在加载歌词…', lyricsLoading: '正在加载歌词…',
lyricsNotFound: '未找到此曲目的歌词', lyricsNotFound: '未找到此曲目的歌词',
}, },
songInfo: {
title: '歌曲信息',
songTitle: '标题',
artist: '艺术家',
album: '专辑',
albumArtist: '专辑艺术家',
year: '年份',
genre: '流派',
duration: '时长',
track: '曲目',
format: '格式',
bitrate: '比特率',
sampleRate: '采样率',
bitDepth: '位深度',
channels: '声道',
fileSize: '文件大小',
path: '文件路径',
replayGainTrack: 'RG 曲目增益',
replayGainAlbum: 'RG 专辑增益',
replayGainPeak: 'RG 曲目峰值',
mono: '单声道',
stereo: '立体声',
},
playlists: { playlists: {
title: '播放列表', title: '播放列表',
newPlaylist: '新建播放列表', newPlaylist: '新建播放列表',
@@ -3120,6 +3279,7 @@ const zhTranslation = {
notFound: '未找到播放列表。', notFound: '未找到播放列表。',
songs: '{{n}} 首歌曲', songs: '{{n}} 首歌曲',
playAll: '全部播放', playAll: '全部播放',
shuffle: '随机播放',
addToQueue: '添加到队列', addToQueue: '添加到队列',
back: '返回播放列表', back: '返回播放列表',
deletePlaylist: '删除', deletePlaylist: '删除',
+9 -3
View File
@@ -112,9 +112,15 @@ const handleEnqueueAll = () => {
}; };
const handlePlaySong = (song: SubsonicSong) => { const handlePlaySong = (song: SubsonicSong) => {
const track = songToTrack(song); if (!album) return;
if (!track.genre && album?.album.genre) track.genre = album.album.genre; const albumGenre = album.album.genre;
playTrack(track, [track]); const tracks = album.songs.map(s => {
const t = songToTrack(s);
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
playTrack(track, tracks);
}; };
const handleRate = async (songId: string, rating: number) => { const handleRate = async (songId: string, rating: number) => {
+13 -2
View File
@@ -11,6 +11,7 @@ export default function Home() {
const [random, setRandom] = useState<SubsonicAlbum[]>([]); const [random, setRandom] = useState<SubsonicAlbum[]>([]);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]); const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]); const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]);
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]); const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -20,13 +21,15 @@ export default function Home() {
getAlbumList('newest', 12).catch(() => []), getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', 20).catch(() => []), getAlbumList('random', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []), getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []),
getArtists().catch(() => []), getArtists().catch(() => []),
]).then(([s, n, r, f, artists]) => { ]).then(([s, n, r, f, rp, artists]) => {
setStarred(s); setStarred(s);
setRecent(n); setRecent(n);
setHeroAlbums(r.slice(0, 8)); setHeroAlbums(r.slice(0, 8));
setRandom(r.slice(8)); setRandom(r.slice(8));
setMostPlayed(f); setMostPlayed(f);
setRecentlyPlayed(rp);
// Pick 16 random artists via Fisher-Yates shuffle // Pick 16 random artists via Fisher-Yates shuffle
const shuffled = [...artists]; const shuffled = [...artists];
for (let i = shuffled.length - 1; i > 0; i--) { for (let i = shuffled.length - 1; i > 0; i--) {
@@ -39,7 +42,7 @@ export default function Home() {
}, []); }, []);
const loadMore = async ( const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent', type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
currentList: SubsonicAlbum[], currentList: SubsonicAlbum[],
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>> setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
) => { ) => {
@@ -96,6 +99,14 @@ export default function Home() {
</div> </div>
</section> </section>
)} )}
{recentlyPlayed.length > 0 && (
<AlbumRow
title={t('home.recentlyPlayed')}
albums={recentlyPlayed}
onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)}
moreText={t('home.loadMore')}
/>
)}
{starred.length > 0 && ( {starred.length > 0 && (
<AlbumRow <AlbumRow
title={t('home.starred')} title={t('home.starred')}
+140 -13
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw } from 'lucide-react'; import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle } from 'lucide-react';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import { import {
getPlaylist, updatePlaylist, search, setRating, star, unstar, getPlaylist, updatePlaylist, search, setRating, star, unstar,
getSimilarSongs2, SubsonicPlaylist, SubsonicSong, getRandomSongs, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic'; } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
@@ -68,6 +69,45 @@ export default function PlaylistDetail() {
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null); const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
// ── Bulk select ───────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showBulkPlPicker, setShowBulkPlPicker] = useState(false);
const toggleSelect = (id: string, idx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdx !== null) {
const from = Math.min(lastSelectedIdx, idx);
const to = Math.max(lastSelectedIdx, idx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
next.has(id) ? next.delete(id) : next.add(id);
}
return next;
});
setLastSelectedIdx(idx);
};
const allSelected = selectedIds.size === songs.length && songs.length > 0;
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
const bulkRemove = () => {
const next = songs.filter(s => !selectedIds.has(s.id));
setSongs(next);
savePlaylist(next);
setSelectedIds(new Set());
};
useEffect(() => {
if (!showBulkPlPicker) return;
const handler = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showBulkPlPicker]);
// ── 2×2 cover quad (first 4 unique album covers) ───────────── // ── 2×2 cover quad (first 4 unique album covers) ─────────────
const coverQuad = useMemo(() => { const coverQuad = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
@@ -140,15 +180,21 @@ export default function PlaylistDetail() {
// ── Suggestions ─────────────────────────────────────────────── // ── Suggestions ───────────────────────────────────────────────
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => { const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
const withArtist = currentSongs.filter(s => s.artistId); if (!currentSongs.length) return;
if (!withArtist.length) return; // Count genres across playlist songs, pick the most common one
const pick = withArtist[Math.floor(Math.random() * withArtist.length)]; const genreCounts: Record<string, number> = {};
for (const s of currentSongs) {
if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1;
}
const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]);
// Fall back to no genre filter if none of the songs have genre tags
const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined;
const existingIds = new Set(currentSongs.map(s => s.id)); const existingIds = new Set(currentSongs.map(s => s.id));
setLoadingSuggestions(true); setLoadingSuggestions(true);
setSuggestions([]); setSuggestions([]);
try { try {
const similar = await getSimilarSongs2(pick.artistId!, 25); const random = await getRandomSongs(25, genre);
setSuggestions(similar.filter(s => !existingIds.has(s.id)).slice(0, 10)); setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10));
} catch {} } catch {}
setLoadingSuggestions(false); setLoadingSuggestions(false);
}, []); }, []);
@@ -352,6 +398,19 @@ export default function PlaylistDetail() {
}}> }}>
<Play size={16} fill="currentColor" /> {t('playlists.playAll')} <Play size={16} fill="currentColor" /> {t('playlists.playAll')}
</button> </button>
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
const tracks = songs.map(songToTrack);
const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
playTrack(shuffled[0], shuffled);
}}>
<Shuffle size={16} /> {t('playlists.shuffle', 'Shuffle')}
</button>
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => { <button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return; if (!songs.length) return;
touchPlaylist(id!); touchPlaylist(id!);
@@ -416,9 +475,53 @@ export default function PlaylistDetail() {
{/* ── Tracklist ── */} {/* ── Tracklist ── */}
<div className="tracklist" ref={tracklistRef}> <div className="tracklist" ref={tracklistRef}>
{/* Bulk action bar */}
{selectedIds.size > 0 && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowBulkPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showBulkPlPicker && (
<AddToPlaylistSubmenu
songIds={[...selectedIds]}
onDone={() => { setShowBulkPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
</div>
<button
className="btn btn-surface btn-sm"
style={{ color: 'var(--danger)' }}
onClick={bulkRemove}
>
<Trash2 size={14} />
{t('common.bulkRemoveFromPlaylist')}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* Header */} {/* Header */}
<div className="tracklist-header tracklist-va tracklist-playlist"> <div className="tracklist-header tracklist-va tracklist-playlist">
<div className="col-center">#</div> <div className="col-center" style={{ cursor: songs.length > 0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}>
{selectedIds.size > 0
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} />
: '#'}
</div>
<div>{t('albumDetail.trackTitle')}</div> <div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div> <div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div> <div className="col-center">{t('albumDetail.trackFavorite')}</div>
@@ -441,7 +544,7 @@ export default function PlaylistDetail() {
<div <div
data-track-idx={idx} data-track-idx={idx}
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`} className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
onMouseEnter={e => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }} onMouseEnter={e => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
onMouseLeave={() => setHoveredSongId(null)} onMouseLeave={() => setHoveredSongId(null)}
onMouseDown={e => handleRowMouseDown(e, idx)} onMouseDown={e => handleRowMouseDown(e, idx)}
@@ -449,26 +552,50 @@ export default function PlaylistDetail() {
const tracks = songs.map(songToTrack); const tracks = songs.map(songToTrack);
playTrack(tracks[idx], tracks); playTrack(tracks[idx], tracks);
}} }}
onClick={e => {
if (selectedIds.size > 0 && !(e.target as HTMLElement).closest('button, input')) {
toggleSelect(song.id, idx, e.shiftKey);
}
}}
onContextMenu={e => { onContextMenu={e => {
e.preventDefault(); e.preventDefault();
setContextMenuSongId(song.id); setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}} }}
> >
{/* # — play on click, grip icon on hover */} {/* # — checkbox in select mode, grip/play on hover otherwise */}
{(() => {
const inSelectMode = selectedIds.size > 0;
return (
<div <div
className="track-num" className="track-num"
style={{ cursor: 'pointer', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }} style={{ cursor: 'pointer' }}
onClick={() => { const tracks = songs.map(songToTrack); playTrack(tracks[idx], tracks); }} onClick={e => {
e.stopPropagation();
if (inSelectMode || hoveredSongId === song.id) {
toggleSelect(song.id, idx, e.shiftKey);
} else {
const tracks = songs.map(songToTrack);
playTrack(tracks[idx], tracks);
}
}}
> >
{hoveredSongId === song.id && currentTrack?.id !== song.id <span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
/>
<span style={{ color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
? <GripVertical size={13} /> ? <GripVertical size={13} />
: currentTrack?.id === song.id && isPlaying : currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div> ? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: currentTrack?.id === song.id : currentTrack?.id === song.id
? <Play size={13} fill="currentColor" /> ? <Play size={13} fill="currentColor" />
: idx + 1} : idx + 1}
</span>
</div> </div>
);
})()}
{/* Title */} {/* Title */}
<div className="track-info"> <div className="track-info">
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, X } from 'lucide-react'; import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
@@ -172,7 +172,7 @@ export default function Playlists() {
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')} data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
data-tooltip-pos="bottom" data-tooltip-pos="bottom"
> >
<X size={12} /> {deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
</button> </button>
</div> </div>
+84 -60
View File
@@ -978,6 +978,17 @@ export default function Settings() {
</label> </label>
</div> </div>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <div>
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div> <div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
@@ -1138,10 +1149,10 @@ function renderInline(text: string): React.ReactNode[] {
}); });
} }
function SidebarGripHandle({ idx, label }: { idx: number; label: string }) { function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({ const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'sidebar_reorder', index: idx }), data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }),
label, label,
})); }));
return ( return (
@@ -1156,69 +1167,103 @@ function SidebarGripHandle({ idx, label }: { idx: number; label: string }) {
); );
} }
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
function SidebarCustomizer() { function SidebarCustomizer() {
const { t } = useTranslation(); const { t } = useTranslation();
const { items, setItems, toggleItem, reset } = useSidebarStore(); const { items, setItems, toggleItem, reset } = useSidebarStore();
const { isDragging: isPsyDragging } = useDragDrop(); const { isDragging: isPsyDragging } = useDragDrop();
const listRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null); const [dropTarget, setDropTarget] = useState<DropTarget>(null);
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null); const dropTargetRef = useRef<DropTarget>(null);
const itemsRef = useRef(items); const itemsRef = useRef(items);
itemsRef.current = items; itemsRef.current = items;
const libraryItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'library');
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
useEffect(() => { useEffect(() => {
if (!isPsyDragging) { if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
dropTargetRef.current = null;
setDropTarget(null);
}
}, [isPsyDragging]); }, [isPsyDragging]);
useEffect(() => { useEffect(() => {
const el = listRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
const onPsyDrop = (e: Event) => { const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail; const detail = (e as CustomEvent).detail;
if (!detail?.data) return; if (!detail?.data) return;
let parsed: { type?: string; index?: number }; let parsed: { type?: string; index?: number; section?: string };
try { parsed = JSON.parse(detail.data); } catch { return; } try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'sidebar_reorder' || parsed.index == null) return; if (parsed.type !== 'sidebar_reorder' || parsed.index == null || !parsed.section) return;
const fromIdx = parsed.index; const fromIdx = parsed.index;
const fromSection = parsed.section as 'library' | 'system';
const target = dropTargetRef.current; const target = dropTargetRef.current;
dropTargetRef.current = null; dropTargetRef.current = null; setDropTarget(null);
setDropTarget(null); if (!target || target.section !== fromSection) return;
if (target === null) return;
const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems];
const insertBefore = target.before ? target.idx : target.idx + 1; const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...itemsRef.current]; const [moved] = sectionItems.splice(fromIdx, 1);
const [moved] = next.splice(fromIdx, 1); sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
const adjustedInsert = insertBefore > fromIdx ? insertBefore - 1 : insertBefore;
next.splice(adjustedInsert, 0, moved); // Merge reordered section back into flat items array
setItems(next); const all = [...itemsRef.current];
const positions = all.map((cfg, i) => ({ cfg, i }))
.filter(({ cfg }) => ALL_NAV_ITEMS[cfg.id]?.section === fromSection)
.map(({ i }) => i);
positions.forEach((pos, i) => { all[pos] = sectionItems[i]; });
setItems(all);
}; };
el.addEventListener('psy-drop', onPsyDrop); el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop); return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [setItems]); }, [libraryItems, systemItems, setItems]);
const handleMouseMove = (e: React.MouseEvent) => { const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !listRef.current) return; if (!isPsyDragging || !containerRef.current) return;
const rows = listRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]'); const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]');
let target: { idx: number; before: boolean } | null = null; let target: DropTarget = null;
for (const row of rows) { for (const row of rows) {
const rect = row.getBoundingClientRect(); const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.sidebarIdx); const idx = Number(row.dataset.sidebarIdx);
if (e.clientY < rect.top + rect.height / 2) { const section = row.dataset.sidebarSection as 'library' | 'system';
target = { idx, before: true }; if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; }
break; target = { idx, before: false, section };
}
target = { idx, before: false };
} }
dropTargetRef.current = target; dropTargetRef.current = target;
setDropTarget(target); setDropTarget(target);
}; };
const renderRow = (cfg: SidebarItemConfig, localIdx: number, section: 'library' | 'system') => {
const meta = ALL_NAV_ITEMS[cfg.id];
if (!meta) return null;
const Icon = meta.icon;
const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && !dropTarget.before;
return (
<div
key={cfg.id}
data-sidebar-idx={localIdx}
data-sidebar-section={section}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
}}
>
<SidebarGripHandle idx={localIdx} section={section} label={t(meta.labelKey)} />
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
<input type="checkbox" checked={cfg.visible} onChange={() => toggleItem(cfg.id)} />
<span className="toggle-track" />
</label>
</div>
);
};
return ( return (
<section className="settings-section"> <section className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
@@ -1233,42 +1278,21 @@ function SidebarCustomizer() {
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
</div> </div>
<div <div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
className="settings-card" {/* Library block */}
style={{ padding: '4px 0' }} <div className="settings-card" style={{ padding: '4px 0' }}>
ref={listRef} <div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
onMouseMove={handleMouseMove} {libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
>
{items.map((cfg, idx) => {
const meta = ALL_NAV_ITEMS[cfg.id];
if (!meta) return null;
const Icon = meta.icon;
const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before;
return (
<div
key={cfg.id}
data-sidebar-idx={idx}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
}}
>
<SidebarGripHandle idx={idx} label={t(meta.labelKey)} />
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
<input type="checkbox" checked={cfg.visible} onChange={() => toggleItem(cfg.id)} />
<span className="toggle-track" />
</label>
</div> </div>
); {/* System block */}
})} <div className="settings-card" style={{ padding: '4px 0' }}>
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
<div className="sidebar-customizer-fixed-hint"> <div className="sidebar-customizer-fixed-hint">
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span> <span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
</div> </div>
</div> </div>
</div>
</section> </section>
); );
} }
+4
View File
@@ -34,6 +34,7 @@ interface AuthState {
crossfadeSecs: number; crossfadeSecs: number;
gaplessEnabled: boolean; gaplessEnabled: boolean;
minimizeToTray: boolean; minimizeToTray: boolean;
nowPlayingEnabled: boolean;
showChangelogOnUpdate: boolean; showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string; lastSeenChangelogVersion: string;
@@ -66,6 +67,7 @@ interface AuthState {
setCrossfadeSecs: (v: number) => void; setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void; setGaplessEnabled: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void; setMinimizeToTray: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void; setLastSeenChangelogVersion: (v: string) => void;
logout: () => void; logout: () => void;
@@ -99,6 +101,7 @@ export const useAuthStore = create<AuthState>()(
crossfadeSecs: 3, crossfadeSecs: 3,
gaplessEnabled: false, gaplessEnabled: false,
minimizeToTray: false, minimizeToTray: false,
nowPlayingEnabled: false,
showChangelogOnUpdate: true, showChangelogOnUpdate: true,
lastSeenChangelogVersion: '', lastSeenChangelogVersion: '',
isLoggedIn: false, isLoggedIn: false,
@@ -164,6 +167,7 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }), setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }), setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
+16 -4
View File
@@ -26,6 +26,8 @@ export interface Track {
replayGainPeak?: number; replayGainPeak?: number;
starred?: string; starred?: string;
genre?: string; genre?: string;
samplingRate?: number;
bitDepth?: number;
} }
export function songToTrack(song: SubsonicSong): Track { export function songToTrack(song: SubsonicSong): Track {
@@ -48,6 +50,8 @@ export function songToTrack(song: SubsonicSong): Track {
replayGainPeak: song.replayGain?.trackPeak, replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred, starred: song.starred,
genre: song.genre, genre: song.genre,
samplingRate: song.samplingRate,
bitDepth: song.bitDepth,
}; };
} }
@@ -112,6 +116,10 @@ interface PlayerState {
}; };
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void; openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
closeContextMenu: () => void; closeContextMenu: () => void;
songInfoModal: { isOpen: boolean; songId: string | null };
openSongInfo: (songId: string) => void;
closeSongInfo: () => void;
} }
// ─── Module-level playback primitives ───────────────────────────────────────── // ─── Module-level playback primitives ─────────────────────────────────────────
@@ -294,8 +302,8 @@ function handleAudioTrackSwitched(duration: number) {
}); });
// Report Now Playing to Navidrome + Last.fm // Report Now Playing to Navidrome + Last.fm
reportNowPlaying(nextTrack.id); const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); if (nowPlayingEnabled) reportNowPlaying(nextTrack.id);
if (lastfmSessionKey) { if (lastfmSessionKey) {
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey); if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => { lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
@@ -439,6 +447,10 @@ export const usePlayerStore = create<PlayerState>()(
contextMenu: { ...state.contextMenu, isOpen: false }, contextMenu: { ...state.contextMenu, isOpen: false },
})), })),
songInfoModal: { isOpen: false, songId: null },
openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }),
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }),
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })), toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
setQueueVisible: (v: boolean) => set({ isQueueVisible: v }), setQueueVisible: (v: boolean) => set({ isQueueVisible: v }),
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })), toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
@@ -558,8 +570,8 @@ export const usePlayerStore = create<PlayerState>()(
}); });
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm // Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
reportNowPlaying(track.id); const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
const { scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState(); if (npEnabled) reportNowPlaying(track.id);
if (lfmKey) { if (lfmKey) {
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey); if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => { lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
+168 -8
View File
@@ -1076,6 +1076,87 @@
margin-top: 4px; margin-top: 4px;
} }
/* ─ Song Info Modal ─ */
.song-info-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9990;
}
.song-info-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9991;
width: 460px;
max-width: calc(100vw - 32px);
max-height: 80vh;
background: var(--bg-card, var(--bg-secondary));
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.song-info-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.song-info-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.02em;
}
.song-info-close {
padding: 4px;
color: var(--text-muted);
}
.song-info-body {
overflow-y: auto;
padding: 12px 4px 16px;
}
.song-info-loading {
text-align: center;
color: var(--text-muted);
font-size: 13px;
padding: 24px;
}
.song-info-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.song-info-label {
color: var(--text-muted);
padding: 4px 16px 4px 16px;
white-space: nowrap;
vertical-align: top;
width: 140px;
font-size: 12px;
}
.song-info-value {
color: var(--text-primary);
padding: 4px 16px 4px 8px;
word-break: break-all;
}
.song-info-divider {
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
}
.song-info-path {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-secondary);
word-break: break-all;
}
/* ─ Tracklist ─ */ /* ─ Tracklist ─ */
.tracklist { .tracklist {
padding: 0 var(--space-6) var(--space-6); padding: 0 var(--space-6) var(--space-6);
@@ -1087,7 +1168,7 @@
.tracklist-header { .tracklist-header {
display: grid; display: grid;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px; grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3); gap: var(--space-3);
align-items: center; align-items: center;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
@@ -1101,12 +1182,12 @@
} }
.tracklist-header.tracklist-va { .tracklist-header.tracklist-va {
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px; grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
} }
.track-row { .track-row {
display: grid; display: grid;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px; grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3); gap: var(--space-3);
align-items: center; align-items: center;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
@@ -1117,12 +1198,12 @@
} }
.track-row.track-row-va { .track-row.track-row-va {
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px; grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
} }
.tracklist-total { .tracklist-total {
display: grid; display: grid;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px; grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3); gap: var(--space-3);
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
@@ -1130,7 +1211,7 @@
} }
.tracklist-total.tracklist-va { .tracklist-total.tracklist-va {
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px; grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
} }
.tracklist-total-label { .tracklist-total-label {
@@ -1164,7 +1245,7 @@
.tracklist-header.tracklist-playlist, .tracklist-header.tracklist-playlist,
.track-row.tracklist-playlist, .track-row.tracklist-playlist,
.tracklist-total.tracklist-playlist { .tracklist-total.tracklist-playlist {
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px; grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px;
} }
.tracklist-total.tracklist-playlist .tracklist-total-label { .tracklist-total.tracklist-playlist .tracklist-total-label {
@@ -1247,10 +1328,22 @@
} }
.track-num { .track-num {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.track-num .bulk-check {
position: absolute;
left: 6px;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s;
}
.track-num .bulk-check.bulk-check-visible {
opacity: 1;
pointer-events: auto;
}
/* Equalizer bars — shown for the currently playing track */ /* Equalizer bars — shown for the currently playing track */
.eq-bars { .eq-bars {
@@ -1990,6 +2083,15 @@
cursor: grabbing; cursor: grabbing;
} }
.sidebar-customizer-block-label {
padding: 6px var(--space-4) 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.sidebar-customizer-fixed-hint { .sidebar-customizer-fixed-hint {
padding: 8px var(--space-4) 6px; padding: 8px var(--space-4) 6px;
font-size: 11px; font-size: 11px;
@@ -4417,9 +4519,67 @@
} }
.playlist-card-delete--confirm { .playlist-card-delete--confirm {
background: rgba(220, 60, 60, 0.9) !important; background: #dc3c3c !important;
color: #fff !important; color: #fff !important;
opacity: 1 !important; opacity: 1 !important;
width: 30px !important;
height: 30px !important;
top: 3px !important;
right: 3px !important;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.45);
animation: delete-confirm-pulse 0.7s ease-in-out infinite alternate;
}
@keyframes delete-confirm-pulse {
from { background: #dc3c3c; }
to { background: #ff2222; }
}
/* ─ Bulk Select ─ */
.bulk-action-bar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--accent-dim, color-mix(in srgb, var(--accent) 15%, transparent));
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius: var(--radius);
margin-bottom: var(--space-2);
}
.bulk-action-count {
font-size: 13px;
font-weight: 600;
color: var(--accent);
margin-right: auto;
}
.bulk-pl-picker-wrap {
position: relative;
}
.bulk-check {
display: inline-block;
width: 14px;
height: 14px;
border: 1.5px solid var(--text-muted);
border-radius: 3px;
background: transparent;
vertical-align: middle;
flex-shrink: 0;
transition: border-color 0.1s, background 0.1s;
}
.bulk-check.checked {
background: var(--accent);
border-color: var(--accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2 6l3 3 5-5' stroke='%23fff' stroke-width='1.8' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 10px 10px;
background-repeat: no-repeat;
background-position: center;
}
.track-row.bulk-selected {
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
}
.track-row.bulk-selected:hover {
background: color-mix(in srgb, var(--accent) 16%, transparent) !important;
} }
/* Drop indicator line */ /* Drop indicator line */
+7 -2
View File
@@ -592,6 +592,11 @@
height: 28px; height: 28px;
} }
/* Star + Last.fm heart — visually separated from track title */
.player-star-btn {
margin-left: var(--space-3);
}
.player-btn-primary { .player-btn-primary {
width: 46px; width: 46px;
height: 46px; height: 46px;
@@ -617,8 +622,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
margin-left: 32px; margin-left: 24px;
margin-right: 32px; margin-right: 24px;
} }
.player-waveform-wrap { .player-waveform-wrap {
+14
View File
@@ -1053,6 +1053,20 @@
--danger: #9d0006; --danger: #9d0006;
} }
/* ── Gruvbox Light Soft overrides ── */
[data-theme='gruvbox-light-soft'] .album-detail-back {
color: #fbf1c7;
}
[data-theme='gruvbox-light-soft'] .album-detail-back:hover {
background: rgba(255, 255, 255, 0.15);
color: #ffffff;
}
[data-theme='gruvbox-light-soft'] .album-detail-badge {
background: var(--accent);
color: #fbf1c7;
}
/* ─── Lambda 17 — Half-Life / City 17 ─── */ /* ─── Lambda 17 — Half-Life / City 17 ─── */
/* HEV orange + Combine Overwatch teal + City 17 concrete */ /* HEV orange + Combine Overwatch teal + City 17 concrete */
[data-theme='lambda-17'] { [data-theme='lambda-17'] {