diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5acf4d..a01d333d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Fixed diff --git a/package.json b/package.json index 2599cb3e..08960c38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.25.1", + "version": "1.26.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index a7bde794..2bc3e62d 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.25.1 +pkgver=1.26.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6383640b..801eaa49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3397,7 +3397,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.25.0" +version = "1.25.1" dependencies = [ "biquad", "md5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8afcf6c1..6ac333cf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.25.1" +version = "1.26.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 68117c13..1fd05a1b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.25.1", + "version": "1.26.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 3eecce50..a38a724c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import PlaylistDetail from './pages/PlaylistDetail'; import NowPlayingPage from './pages/NowPlaying'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; +import SongInfoModal from './components/SongInfoModal'; import DownloadFolderModal from './components/DownloadFolderModal'; import { DragDropProvider } from './contexts/DragDropContext'; import TooltipPortal from './components/TooltipPortal'; @@ -272,6 +273,7 @@ function AppShell() { )} + {changelogModalOpen && setChangelogModalOpen(false)} />} diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 4ba77794..24f19edc 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -74,6 +74,7 @@ export interface SubsonicSong { contentType?: string; size?: number; samplingRate?: number; + bitDepth?: number; channelCount?: number; starred?: string; genre?: string; @@ -325,7 +326,7 @@ export async function reportNowPlaying(id: string): Promise { } // ─── Stream URL ─────────────────────────────────────────────── -export function buildStreamUrl(id: string, suffix?: string): string { +export function buildStreamUrl(id: string): string { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); 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', }); - 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()}`; } diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 8964afdd..6f66e6dd 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect } from 'react'; -import { Play, Star } from 'lucide-react'; +import React, { useState, useEffect, useMemo } from 'react'; +import { Play, Star, ListPlus, X } from 'lucide-react'; import { SubsonicSong } from '../api/subsonic'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; +import { AddToPlaylistSubmenu } from './ContextMenu'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); @@ -71,9 +72,45 @@ export default function AlbumTrackList({ const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const psyDrag = useDragDrop(); + + // ── Bulk select ─────────────────────────────────────────────────── + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastSelectedIdx, setLastSelectedIdx] = useState(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(() => { if (!contextMenuOpen) setContextMenuSongId(null); }, [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 discs = new Map(); @@ -83,12 +120,51 @@ export default function AlbumTrackList({ discs.get(disc)!.push(song); }); 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 (
+ + {/* ── Bulk action bar ── */} + {inSelectMode && ( +
+ + {t('common.bulkSelected', { count: selectedIds.size })} + +
+ + {showPlPicker && ( + { setShowPlPicker(false); setSelectedIds(new Set()); }} + dropDown + /> + )} +
+ +
+ )} +
-
#
+
+ {inSelectMode + ? + : '#'} +
{t('albumDetail.trackTitle')}
{t('albumDetail.trackArtist')}
{t('albumDetail.trackFavorite')}
@@ -105,81 +181,99 @@ export default function AlbumTrackList({ CD {discNum}
)} - {discs.get(discNum)!.map((song, i) => ( -
setHoveredSongId(song.id)} - onMouseLeave={() => setHoveredSongId(null)} - onDoubleClick={() => onPlaySong(song)} - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); - }} - role="row" - onMouseDown={e => { - if (e.button !== 0) return; - e.preventDefault(); - const sx = e.clientX, sy = e.clientY; - const onMove = (me: MouseEvent) => { - if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY); - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > + {discs.get(discNum)!.map((song) => { + const globalIdx = songs.indexOf(song); + return (
setHoveredSongId(song.id)} + onMouseLeave={() => setHoveredSongId(null)} + onDoubleClick={() => onPlaySong(song)} + onClick={e => { + if (inSelectMode && !(e.target as HTMLElement).closest('button, input')) { + toggleSelect(song.id, globalIdx, e.shiftKey); + } + }} + onContextMenu={e => { + e.preventDefault(); + setContextMenuSongId(song.id); + onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); + }} + role="row" + onMouseDown={e => { + if (e.button !== 0) return; + e.preventDefault(); + const sx = e.clientX, sy = e.clientY; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY); + } + }; + const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); }} - onClick={() => onPlaySong(song)} > - {hoveredSongId === song.id && currentTrack?.id !== song.id - ? - : currentTrack?.id === song.id && isPlaying - ?
- : currentTrack?.id === song.id - ? - : (song.track ?? i + 1)} -
-
- {song.title} -
-
- {song.artist} -
-
- + { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }} + /> + + {hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode + ? + : currentTrack?.id === song.id && isPlaying + ?
+ : currentTrack?.id === song.id + ? + : (song.track ?? globalIdx + 1)} +
+
+
+ {song.title} +
+
+ {song.artist} +
+
+ +
+ onRate(song.id, r)} + /> +
+ {formatDuration(song.duration)} +
+
+ {(song.suffix || song.bitRate) && ( + {codecLabel(song)} + )} +
- onRate(song.id, r)} - /> -
- {formatDuration(song.duration)} -
-
- {(song.suffix || song.bitRate) && ( - {codecLabel(song)} - )} -
-
- ))} + ); + })} ))} diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index a0e60aa4..1a33654a 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { getCachedUrl } from '../utils/imageCache'; interface CachedImageProps extends React.ImgHTMLAttributes { @@ -6,31 +6,38 @@ interface CachedImageProps extends React.ImgHTMLAttributes { 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 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(''); useEffect(() => { if (!fetchUrl) { setResolved(''); return; } let cancelled = false; + setResolved(''); getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); }); return () => { cancelled = true; }; }, [fetchUrl, cacheKey]); - return resolved || fetchUrl; + return fallbackToFetch ? (resolved || fetchUrl) : resolved; } export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) { const resolvedSrc = useCachedUrl(src, cacheKey); const [loaded, setLoaded] = useState(false); - const prevSrc = useRef(''); - if (resolvedSrc !== prevSrc.current) { - prevSrc.current = resolvedSrc; + // Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl + // URL upgrades within the same image — avoids the end-of-load flash. + useEffect(() => { setLoaded(false); - } + }, [cacheKey]); return ( { setLoaded(true); onLoad?.(e); }} {...props} /> diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 9e7ecd7f..465bb08d 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,5 +1,5 @@ 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 { usePlayerStore, Track, songToTrack } from '../store/playerStore'; 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 ─────────────────────────────────────── -function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) { +export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) { const { t } = useTranslation(); const subRef = useRef(null); const newNameRef = useRef(null); @@ -84,9 +84,11 @@ function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: onDone(); }; - const subStyle: React.CSSProperties = flipLeft - ? { right: 'calc(100% + 4px)', left: 'auto' } - : { left: 'calc(100% + 4px)', right: 'auto' }; + const subStyle: React.CSSProperties = dropDown + ? { top: 'calc(100% + 4px)', left: 0, right: 'auto' } + : flipLeft + ? { right: 'calc(100% + 4px)', left: 'auto' } + : { left: 'calc(100% + 4px)', right: 'auto' }; return (
@@ -160,7 +162,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: export default function ContextMenu() { 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 requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const navigate = useNavigate(); @@ -320,6 +322,10 @@ export default function ContextMenu() {
); })()} +
+
handleAction(() => openSongInfo(song.id))}> + {t('contextMenu.songInfo')} +
); })()} @@ -436,6 +442,10 @@ export default function ContextMenu() {
handleAction(() => startRadio(song.artist, song.artist))}> {t('contextMenu.startRadio')}
+
+
handleAction(() => openSongInfo(song.id))}> + {t('contextMenu.songInfo')} +
); })()} diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 03c0e62a..024203e3 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -62,16 +62,25 @@ const FsBg = memo(function FsBg({ url }: { url: string }) { useEffect(() => { if (!url) return; + let cancelled = false; const id = counterRef.current++; - setLayers(prev => [...prev, { url, id, visible: false }]); - const t1 = setTimeout(() => { - setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); - }, 20); - const t2 = setTimeout(() => { - setLayers(prev => prev.filter(l => l.id === id)); - }, 800); - return () => { clearTimeout(t1); clearTimeout(t2); }; - }, [url]); // eslint-disable-line react-hooks/exhaustive-deps + // 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 }]); + requestAnimationFrame(() => { + if (cancelled) return; + setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); + setTimeout(() => { + if (!cancelled) setLayers(prev => prev.filter(l => l.id === id)); + }, 800); + }); + }; + img.src = url; + return () => { cancelled = true; }; + }, [url]); return ( <> @@ -158,8 +167,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { // 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 coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]); - // useCachedUrl must be called unconditionally (hook rules) - const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); + // No fetchUrl fallback for the background — we only want stable blob URLs + // 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 const [artistBgUrl, setArtistBgUrl] = useState(''); diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index eba497df..a0b8e85b 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -2,11 +2,11 @@ import React, { useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { 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'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import CachedImage from './CachedImage'; import WaveformSeek from './WaveformSeek'; import Equalizer from './Equalizer'; @@ -35,9 +35,26 @@ export default function PlayerBar() { stop, toggleRepeat, repeatMode, toggleFullscreen, lastfmLoved, toggleLastfmLove, isQueueVisible, toggleQueue, + starredOverrides, setStarredOverride, } = usePlayerStore(); 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 coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]); const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : ''; @@ -92,6 +109,17 @@ export default function PlayerBar() { onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} />
+ {currentTrack && ( + + )} {currentTrack && lastfmSessionKey && ( +
+ +
+ {loading &&
{t('common.loading')}
} + + {!loading && song && ( + + + + + + {song.albumArtist && song.albumArtist !== song.artist && ( + + )} + + + + + + + + + + + + + + + {song.path && ( + <> + + {song.path}} /> + + )} + + {hasReplayGain && ( + <> + + {song.replayGain!.trackGain !== undefined && ( + = 0 ? '+' : ''}${song.replayGain!.trackGain.toFixed(2)} dB`} /> + )} + {song.replayGain!.albumGain !== undefined && ( + = 0 ? '+' : ''}${song.replayGain!.albumGain.toFixed(2)} dB`} /> + )} + {song.replayGain!.trackPeak !== undefined && ( + + )} + + )} + +
+ )} +
+ + , + document.body + ); +} diff --git a/src/i18n.ts b/src/i18n.ts index 913346a5..d67c98d9 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -31,6 +31,7 @@ const enTranslation = { starred: 'Personal Favorites', recent: 'Recently Added', mostPlayed: 'Most Played', + recentlyPlayed: 'Recently Played', discover: 'Discover', loadMore: 'Load More', discoverMore: 'Discover More', @@ -102,6 +103,7 @@ const enTranslation = { goToArtist: 'Go to Artist', download: 'Download (ZIP)', addToPlaylist: 'Add to Playlist', + songInfo: 'Song Info', }, albumDetail: { back: 'Back', @@ -300,6 +302,10 @@ const enTranslation = { filterSearchGenres: 'Search genres…', filterNoGenres: 'No genres match', filterClear: 'Clear', + bulkSelected: '{{count}} selected', + bulkAddToPlaylist: 'Add to Playlist', + bulkRemoveFromPlaylist: 'Remove from Playlist', + bulkClear: 'Clear selection', }, settings: { title: 'Settings', @@ -363,6 +369,8 @@ const enTranslation = { cacheClearCancel: 'Cancel', minimizeToTray: 'Minimize to Tray', 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', downloadsDefault: 'Default Downloads Folder', pickFolder: 'Select', @@ -600,6 +608,29 @@ const enTranslation = { lyricsLoading: 'Loading lyrics…', 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: { title: 'Playlists', newPlaylist: 'New Playlist', @@ -612,6 +643,7 @@ const enTranslation = { notFound: 'Playlist not found.', songs: '{{n}} songs', playAll: 'Play All', + shuffle: 'Shuffle', addToQueue: 'Add to Queue', back: 'Back to Playlists', deletePlaylist: 'Delete', @@ -658,6 +690,7 @@ const deTranslation = { starred: 'Persönliche Favoriten', recent: 'Zuletzt hinzugefügt', mostPlayed: 'Meistgehört', + recentlyPlayed: 'Kürzlich gespielt', discover: 'Entdecken', loadMore: 'Mehr laden', discoverMore: 'Mehr entdecken', @@ -729,6 +762,7 @@ const deTranslation = { goToArtist: 'Zum Künstler', download: 'Herunterladen (ZIP)', addToPlaylist: 'Zur Playlist hinzufügen', + songInfo: 'Song-Infos', }, albumDetail: { back: 'Zurück', @@ -927,6 +961,10 @@ const deTranslation = { filterSearchGenres: 'Genres suchen…', filterNoGenres: 'Keine Genres gefunden', filterClear: 'Zurücksetzen', + bulkSelected: '{{count}} ausgewählt', + bulkAddToPlaylist: 'Zur Playlist hinzufügen', + bulkRemoveFromPlaylist: 'Aus Playlist entfernen', + bulkClear: 'Auswahl aufheben', }, settings: { title: 'Einstellungen', @@ -990,6 +1028,8 @@ const deTranslation = { cacheClearCancel: 'Abbrechen', minimizeToTray: 'Im Tray minimieren', 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', downloadsDefault: 'Standard-Downloads-Ordner', pickFolder: 'Auswählen', @@ -1227,6 +1267,29 @@ const deTranslation = { lyricsLoading: 'Lyrics werden geladen…', 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: { title: 'Playlists', newPlaylist: 'Neue Playlist', @@ -1239,6 +1302,7 @@ const deTranslation = { notFound: 'Playlist nicht gefunden.', songs: '{{n}} Songs', playAll: 'Alle abspielen', + shuffle: 'Zufallswiedergabe', addToQueue: 'Zur Warteschlange', back: 'Zurück zu Playlists', deletePlaylist: 'Löschen', @@ -1285,6 +1349,7 @@ const frTranslation = { starred: 'Favoris personnels', recent: 'Ajoutés récemment', mostPlayed: 'Les plus écoutés', + recentlyPlayed: 'Récemment écoutés', discover: 'Découvrir', loadMore: 'Charger plus', discoverMore: 'Découvrir plus', @@ -1356,6 +1421,7 @@ const frTranslation = { goToArtist: 'Aller à l\'artiste', download: 'Télécharger (ZIP)', addToPlaylist: 'Ajouter à la playlist', + songInfo: 'Infos du morceau', }, albumDetail: { back: 'Retour', @@ -1554,6 +1620,10 @@ const frTranslation = { filterSearchGenres: 'Rechercher des genres…', filterNoGenres: 'Aucun genre trouvé', filterClear: 'Effacer', + bulkSelected: '{{count}} sélectionné(s)', + bulkAddToPlaylist: 'Ajouter à la playlist', + bulkRemoveFromPlaylist: 'Retirer de la playlist', + bulkClear: 'Désélectionner', }, settings: { title: 'Paramètres', @@ -1617,6 +1687,8 @@ const frTranslation = { cacheClearCancel: 'Annuler', 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.', + 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', downloadsDefault: 'Dossier de téléchargement par défaut', pickFolder: 'Sélectionner', @@ -1854,6 +1926,29 @@ const frTranslation = { lyricsLoading: 'Chargement des paroles…', 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: { title: 'Playlists', newPlaylist: 'Nouvelle playlist', @@ -1866,6 +1961,7 @@ const frTranslation = { notFound: 'Playlist introuvable.', songs: '{{n}} titres', playAll: 'Tout lire', + shuffle: 'Aléatoire', addToQueue: 'Ajouter à la file', back: 'Retour aux playlists', deletePlaylist: 'Supprimer', @@ -1912,6 +2008,7 @@ const nlTranslation = { starred: 'Persoonlijke favorieten', recent: 'Recent toegevoegd', mostPlayed: 'Meest gespeeld', + recentlyPlayed: 'Recent afgespeeld', discover: 'Ontdekken', loadMore: 'Meer laden', discoverMore: 'Meer ontdekken', @@ -1983,6 +2080,7 @@ const nlTranslation = { goToArtist: 'Naar artiest', download: 'Downloaden (ZIP)', addToPlaylist: 'Toevoegen aan playlist', + songInfo: 'Nummerinfo', }, albumDetail: { back: 'Terug', @@ -2181,6 +2279,10 @@ const nlTranslation = { filterSearchGenres: 'Genres zoeken…', filterNoGenres: 'Geen genres gevonden', filterClear: 'Wissen', + bulkSelected: '{{count}} geselecteerd', + bulkAddToPlaylist: 'Toevoegen aan afspeellijst', + bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst', + bulkClear: 'Selectie wissen', }, settings: { title: 'Instellingen', @@ -2244,6 +2346,8 @@ const nlTranslation = { cacheClearCancel: 'Annuleren', minimizeToTray: 'Minimaliseren naar systeemvak', 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', downloadsDefault: 'Standaard downloadmap', pickFolder: 'Selecteren', @@ -2481,6 +2585,29 @@ const nlTranslation = { lyricsLoading: 'Songtekst laden…', 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: { title: 'Playlists', newPlaylist: 'Nieuwe playlist', @@ -2493,6 +2620,7 @@ const nlTranslation = { notFound: 'Playlist niet gevonden.', songs: '{{n}} nummers', playAll: 'Alles afspelen', + shuffle: 'Willekeurig', addToQueue: 'Aan wachtrij toevoegen', back: 'Terug naar playlists', deletePlaylist: 'Verwijderen', @@ -2539,6 +2667,7 @@ const zhTranslation = { starred: '个人收藏', recent: '最近添加', mostPlayed: '最常播放', + recentlyPlayed: '最近播放', discover: '发现', loadMore: '加载更多', discoverMore: '发现更多', @@ -2610,6 +2739,7 @@ const zhTranslation = { goToArtist: '前往艺术家', download: '下载 (ZIP)', addToPlaylist: '添加到播放列表', + songInfo: '歌曲信息', }, albumDetail: { back: '返回', @@ -2808,6 +2938,10 @@ const zhTranslation = { filterSearchGenres: '搜索流派…', filterNoGenres: '未找到匹配流派', filterClear: '清除', + bulkSelected: '已选 {{count}} 首', + bulkAddToPlaylist: '添加到播放列表', + bulkRemoveFromPlaylist: '从播放列表移除', + bulkClear: '取消选择', }, settings: { title: '设置', @@ -2871,6 +3005,8 @@ const zhTranslation = { cacheClearCancel: '取消', minimizeToTray: '最小化到托盘', minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。', + nowPlayingEnabled: '在实时窗口中显示', + nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。', downloadsTitle: '下载文件夹', downloadsDefault: '默认下载文件夹', pickFolder: '选择', @@ -3108,6 +3244,29 @@ const zhTranslation = { lyricsLoading: '正在加载歌词…', 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: { title: '播放列表', newPlaylist: '新建播放列表', @@ -3120,6 +3279,7 @@ const zhTranslation = { notFound: '未找到播放列表。', songs: '{{n}} 首歌曲', playAll: '全部播放', + shuffle: '随机播放', addToQueue: '添加到队列', back: '返回播放列表', deletePlaylist: '删除', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 08886f8a..3ce76626 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -112,9 +112,15 @@ const handleEnqueueAll = () => { }; const handlePlaySong = (song: SubsonicSong) => { - const track = songToTrack(song); - if (!track.genre && album?.album.genre) track.genre = album.album.genre; - playTrack(track, [track]); + if (!album) return; + const albumGenre = album.album.genre; + 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) => { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index cab18fe4..f08f5ef4 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -11,6 +11,7 @@ export default function Home() { const [random, setRandom] = useState([]); const [heroAlbums, setHeroAlbums] = useState([]); const [mostPlayed, setMostPlayed] = useState([]); + const [recentlyPlayed, setRecentlyPlayed] = useState([]); const [randomArtists, setRandomArtists] = useState([]); const [loading, setLoading] = useState(true); @@ -20,13 +21,15 @@ export default function Home() { getAlbumList('newest', 12).catch(() => []), getAlbumList('random', 20).catch(() => []), getAlbumList('frequent', 12).catch(() => []), + getAlbumList('recent', 12).catch(() => []), getArtists().catch(() => []), - ]).then(([s, n, r, f, artists]) => { + ]).then(([s, n, r, f, rp, artists]) => { setStarred(s); setRecent(n); setHeroAlbums(r.slice(0, 8)); setRandom(r.slice(8)); setMostPlayed(f); + setRecentlyPlayed(rp); // Pick 16 random artists via Fisher-Yates shuffle const shuffled = [...artists]; for (let i = shuffled.length - 1; i > 0; i--) { @@ -39,7 +42,7 @@ export default function Home() { }, []); const loadMore = async ( - type: 'starred' | 'newest' | 'random' | 'frequent', + type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent', currentList: SubsonicAlbum[], setter: React.Dispatch> ) => { @@ -96,6 +99,14 @@ export default function Home() { )} + {recentlyPlayed.length > 0 && ( + loadMore('recent', recentlyPlayed, setRecentlyPlayed)} + moreText={t('home.loadMore')} + /> + )} {starred.length > 0 && ( (null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); + // ── Bulk select ─────────────────────────────────────────────────── + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastSelectedIdx, setLastSelectedIdx] = useState(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) ───────────── const coverQuad = useMemo(() => { const seen = new Set(); @@ -140,15 +180,21 @@ export default function PlaylistDetail() { // ── Suggestions ─────────────────────────────────────────────── const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => { - const withArtist = currentSongs.filter(s => s.artistId); - if (!withArtist.length) return; - const pick = withArtist[Math.floor(Math.random() * withArtist.length)]; + if (!currentSongs.length) return; + // Count genres across playlist songs, pick the most common one + const genreCounts: Record = {}; + 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)); setLoadingSuggestions(true); setSuggestions([]); try { - const similar = await getSimilarSongs2(pick.artistId!, 25); - setSuggestions(similar.filter(s => !existingIds.has(s.id)).slice(0, 10)); + const random = await getRandomSongs(25, genre); + setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10)); } catch {} setLoadingSuggestions(false); }, []); @@ -352,6 +398,19 @@ export default function PlaylistDetail() { }}> {t('playlists.playAll')} + + {showBulkPlPicker && ( + { setShowBulkPlPicker(false); setSelectedIds(new Set()); }} + dropDown + /> + )} + + + + + )} + {/* Header */}
-
#
+
0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}> + {selectedIds.size > 0 + ? + : '#'} +
{t('albumDetail.trackTitle')}
{t('albumDetail.trackArtist')}
{t('albumDetail.trackFavorite')}
@@ -441,7 +544,7 @@ export default function PlaylistDetail() {
{ setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }} onMouseLeave={() => setHoveredSongId(null)} onMouseDown={e => handleRowMouseDown(e, idx)} @@ -449,26 +552,50 @@ export default function PlaylistDetail() { const tracks = songs.map(songToTrack); 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 => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); }} > - {/* # — play on click, grip icon on hover */} -
{ const tracks = songs.map(songToTrack); playTrack(tracks[idx], tracks); }} - > - {hoveredSongId === song.id && currentTrack?.id !== song.id - ? - : currentTrack?.id === song.id && isPlaying - ?
- : currentTrack?.id === song.id - ? - : idx + 1} -
+ {/* # — checkbox in select mode, grip/play on hover otherwise */} + {(() => { + const inSelectMode = selectedIds.size > 0; + return ( +
{ + e.stopPropagation(); + if (inSelectMode || hoveredSongId === song.id) { + toggleSelect(song.id, idx, e.shiftKey); + } else { + const tracks = songs.map(songToTrack); + playTrack(tracks[idx], tracks); + } + }} + > + { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }} + /> + + {hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode + ? + : currentTrack?.id === song.id && isPlaying + ?
+ : currentTrack?.id === song.id + ? + : idx + 1} +
+
+ ); + })()} {/* Title */}
diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 995fd236..6dff0aa1 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef } from 'react'; 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 { usePlayerStore, songToTrack } from '../store/playerStore'; 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-pos="bottom" > - + {deleteConfirmId === pl.id ? : }
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index a439926b..94f43745 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -978,6 +978,17 @@ export default function Settings() {
+
+
+
{t('settings.nowPlayingEnabled')}
+
{t('settings.nowPlayingEnabledDesc')}
+
+ +
+
{t('settings.downloadsTitle')}
@@ -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 { onMouseDown } = useDragSource(() => ({ - data: JSON.stringify({ type: 'sidebar_reorder', index: idx }), + data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }), label, })); 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() { const { t } = useTranslation(); const { items, setItems, toggleItem, reset } = useSidebarStore(); const { isDragging: isPsyDragging } = useDragDrop(); - const listRef = useRef(null); - const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null); - const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null); + const containerRef = useRef(null); + const [dropTarget, setDropTarget] = useState(null); + const dropTargetRef = useRef(null); const itemsRef = useRef(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(() => { - if (!isPsyDragging) { - dropTargetRef.current = null; - setDropTarget(null); - } + if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); } }, [isPsyDragging]); useEffect(() => { - const el = listRef.current; + const el = containerRef.current; if (!el) return; const onPsyDrop = (e: Event) => { const detail = (e as CustomEvent).detail; 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; } - 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 fromSection = parsed.section as 'library' | 'system'; const target = dropTargetRef.current; - dropTargetRef.current = null; - setDropTarget(null); - if (target === null) return; + dropTargetRef.current = null; setDropTarget(null); + if (!target || target.section !== fromSection) return; + const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems]; const insertBefore = target.before ? target.idx : target.idx + 1; if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; - const next = [...itemsRef.current]; - const [moved] = next.splice(fromIdx, 1); - const adjustedInsert = insertBefore > fromIdx ? insertBefore - 1 : insertBefore; - next.splice(adjustedInsert, 0, moved); - setItems(next); + const [moved] = sectionItems.splice(fromIdx, 1); + sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); + + // Merge reordered section back into flat items array + 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); return () => el.removeEventListener('psy-drop', onPsyDrop); - }, [setItems]); + }, [libraryItems, systemItems, setItems]); const handleMouseMove = (e: React.MouseEvent) => { - if (!isPsyDragging || !listRef.current) return; - const rows = listRef.current.querySelectorAll('[data-sidebar-idx]'); - let target: { idx: number; before: boolean } | null = null; + if (!isPsyDragging || !containerRef.current) return; + const rows = containerRef.current.querySelectorAll('[data-sidebar-idx]'); + let target: DropTarget = null; for (const row of rows) { const rect = row.getBoundingClientRect(); const idx = Number(row.dataset.sidebarIdx); - if (e.clientY < rect.top + rect.height / 2) { - target = { idx, before: true }; - break; - } - target = { idx, before: false }; + const section = row.dataset.sidebarSection as 'library' | 'system'; + if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; } + target = { idx, before: false, section }; } dropTargetRef.current = 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 ( +
+ + + {t(meta.labelKey)} + +
+ ); + }; + return (
@@ -1233,40 +1278,19 @@ function SidebarCustomizer() {
-
- {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 ( -
- - - {t(meta.labelKey)} - -
- ); - })} -
- {t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')} +
+ {/* Library block */} +
+
{t('sidebar.library')}
+ {libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))} +
+ {/* System block */} +
+
{t('sidebar.system')}
+ {systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))} +
+ {t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')} +
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index bde1f6b3..d844b63a 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -34,6 +34,7 @@ interface AuthState { crossfadeSecs: number; gaplessEnabled: boolean; minimizeToTray: boolean; + nowPlayingEnabled: boolean; showChangelogOnUpdate: boolean; lastSeenChangelogVersion: string; @@ -66,6 +67,7 @@ interface AuthState { setCrossfadeSecs: (v: number) => void; setGaplessEnabled: (v: boolean) => void; setMinimizeToTray: (v: boolean) => void; + setNowPlayingEnabled: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void; setLastSeenChangelogVersion: (v: string) => void; logout: () => void; @@ -99,6 +101,7 @@ export const useAuthStore = create()( crossfadeSecs: 3, gaplessEnabled: false, minimizeToTray: false, + nowPlayingEnabled: false, showChangelogOnUpdate: true, lastSeenChangelogVersion: '', isLoggedIn: false, @@ -164,6 +167,7 @@ export const useAuthStore = create()( setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), setGaplessEnabled: (v) => set({ gaplessEnabled: v }), setMinimizeToTray: (v) => set({ minimizeToTray: v }), + setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index adea1df1..2de560ed 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -26,6 +26,8 @@ export interface Track { replayGainPeak?: number; starred?: string; genre?: string; + samplingRate?: number; + bitDepth?: number; } export function songToTrack(song: SubsonicSong): Track { @@ -48,6 +50,8 @@ export function songToTrack(song: SubsonicSong): Track { replayGainPeak: song.replayGain?.trackPeak, starred: song.starred, 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; closeContextMenu: () => void; + + songInfoModal: { isOpen: boolean; songId: string | null }; + openSongInfo: (songId: string) => void; + closeSongInfo: () => void; } // ─── Module-level playback primitives ───────────────────────────────────────── @@ -294,8 +302,8 @@ function handleAudioTrackSwitched(duration: number) { }); // Report Now Playing to Navidrome + Last.fm - reportNowPlaying(nextTrack.id); - const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); + const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); + if (nowPlayingEnabled) reportNowPlaying(nextTrack.id); if (lastfmSessionKey) { if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey); lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => { @@ -439,6 +447,10 @@ export const usePlayerStore = create()( 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 })), setQueueVisible: (v: boolean) => set({ isQueueVisible: v }), toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })), @@ -558,8 +570,8 @@ export const usePlayerStore = create()( }); // Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm - reportNowPlaying(track.id); - const { scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState(); + const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState(); + if (npEnabled) reportNowPlaying(track.id); if (lfmKey) { if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey); lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => { diff --git a/src/styles/components.css b/src/styles/components.css index d34bd854..592331f0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1076,6 +1076,87 @@ 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 { padding: 0 var(--space-6) var(--space-6); @@ -1087,7 +1168,7 @@ .tracklist-header { 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); align-items: center; padding: var(--space-2) var(--space-3); @@ -1101,12 +1182,12 @@ } .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 { 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); align-items: center; padding: var(--space-2) var(--space-3); @@ -1117,12 +1198,12 @@ } .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 { 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); border-top: 1px solid var(--border-subtle); padding: var(--space-2) var(--space-3); @@ -1130,7 +1211,7 @@ } .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 { @@ -1164,7 +1245,7 @@ .tracklist-header.tracklist-playlist, .track-row.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 { @@ -1247,10 +1328,22 @@ } .track-num { + position: relative; display: flex; align-items: 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 */ .eq-bars { @@ -1990,6 +2083,15 @@ 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 { padding: 8px var(--space-4) 6px; font-size: 11px; @@ -4417,9 +4519,67 @@ } .playlist-card-delete--confirm { - background: rgba(220, 60, 60, 0.9) !important; + background: #dc3c3c !important; color: #fff !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 */ diff --git a/src/styles/layout.css b/src/styles/layout.css index fab82d91..201e9a6a 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -592,6 +592,11 @@ height: 28px; } +/* Star + Last.fm heart — visually separated from track title */ +.player-star-btn { + margin-left: var(--space-3); +} + .player-btn-primary { width: 46px; height: 46px; @@ -617,8 +622,8 @@ display: flex; align-items: center; gap: var(--space-2); - margin-left: 32px; - margin-right: 32px; + margin-left: 24px; + margin-right: 24px; } .player-waveform-wrap { diff --git a/src/styles/theme.css b/src/styles/theme.css index 829e1341..12924692 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -1053,6 +1053,20 @@ --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 ─── */ /* HEV orange + Combine Overwatch teal + City 17 concrete */ [data-theme='lambda-17'] {