feat: v1.15.0 — Genre Strip, Lyrics Accent, Sidebar Button fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-23 18:47:32 +01:00
parent 361e9cfdb3
commit 3effad0830
20 changed files with 121 additions and 78 deletions
+17
View File
@@ -5,6 +5,23 @@ 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.15.0] - 2026-03-23
### Added
- **Queue — Genre · Format · Bitrate Strip**: The meta box above the queue now shows a full-width frosted strip with Genre, audio format, and bitrate (e.g. `Electronic · FLAC · 1411 kbps`). Genre is sourced directly from track metadata and is now propagated through all 11 track construction sites across the codebase.
- **Lyrics — Accent Color Highlight**: The active synced lyrics line is now highlighted in the theme accent color instead of bold+larger text. Eliminates layout jumps caused by the font-weight change pushing lines to wrap.
### Fixed
- **Sidebar — Collapse Button**: The collapse button now correctly sits on the right border of the sidebar, straddling the dividing line between sidebar and main content, and is always visible.
### Changed
- **Queue — Tech Info**: Codec/bitrate badge replaced by the new full-width Genre · Format · Bitrate strip at the top of the meta box.
---
## [1.14.0] - 2026-03-22 ## [1.14.0] - 2026-03-22
### Critical Fixes ### Critical Fixes
+1 -1
View File
@@ -246,4 +246,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`). - **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact. - **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors. - **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
- **Version**: 1.14.0 - **Version**: 1.15.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.14.0", "version": "1.15.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.14.0 pkgver=1.15.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
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.14.0" version = "1.15.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.14.0", "version": "1.15.0",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+5 -5
View File
@@ -135,9 +135,9 @@ function AppShell() {
} as React.CSSProperties} } as React.CSSProperties}
onContextMenu={e => e.preventDefault()} onContextMenu={e => e.preventDefault()}
> >
<Sidebar <Sidebar
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)} toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/> />
<main className="main-content"> <main className="main-content">
<header className="content-header"> <header className="content-header">
@@ -147,12 +147,12 @@ function AppShell() {
<LastfmIndicator /> <LastfmIndicator />
<NowPlayingDropdown /> <NowPlayingDropdown />
<button <button
className="collapse-btn" className="queue-toggle-btn"
onClick={toggleQueue} onClick={toggleQueue}
data-tooltip={t('player.toggleQueue')} data-tooltip={t('player.toggleQueue')}
data-tooltip-pos="bottom" data-tooltip-pos="bottom"
> >
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />} {isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button> </button>
</header> </header>
<div className="content-body" style={{ padding: 0, position: 'relative' }}> <div className="content-body" style={{ padding: 0, position: 'relative' }}>
+1 -1
View File
@@ -86,7 +86,7 @@ export default function AlbumTrackList({
albumId: song.albumId, artistId: song.artistId, duration: song.duration, albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, track: song.track, year: song.year, coverArt: song.coverArt, track: song.track, year: song.year,
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred, starred: song.starred, genre: song.genre,
}); });
return ( return (
+2 -2
View File
@@ -69,7 +69,7 @@ export default function ContextMenu() {
const radioTracks = [...top, ...similar].map(s => ({ const radioTracks = [...top, ...similar].map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})); }));
playTrack(radioTracks[0], radioTracks); playTrack(radioTracks[0], radioTracks);
} }
@@ -135,7 +135,7 @@ export default function ContextMenu() {
const tracks = albumData.songs.map(s => ({ const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})); }));
enqueue(tracks); enqueue(tracks);
})}> })}>
+1 -1
View File
@@ -153,7 +153,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const tracks = albumData.songs.map(s => ({ const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})); }));
usePlayerStore.getState().enqueue(tracks); usePlayerStore.getState().enqueue(tracks);
} catch (_) { } } catch (_) { }
+2 -2
View File
@@ -58,7 +58,7 @@ export default function LiveSearch() {
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => { ...(results.songs.map(s => ({ id: s.id, action: () => {
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating }); playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre });
setOpen(false); setQuery(''); setOpen(false); setQuery('');
}}))), }}))),
] : []; ] : [];
@@ -176,7 +176,7 @@ export default function LiveSearch() {
return ( return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`} <button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => { onClick={() => {
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating }); playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre });
setOpen(false); setQuery(''); setOpen(false); setQuery('');
}} }}
role="option" aria-selected={activeIndex === i}> role="option" aria-selected={activeIndex === i}>
+34 -29
View File
@@ -270,7 +270,7 @@ export default function QueuePanel() {
const tracks: Track[] = albumData.songs.map(s => ({ const tracks: Track[] = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})); }));
enqueue(tracks); enqueue(tracks);
} }
@@ -318,36 +318,41 @@ export default function QueuePanel() {
</div> </div>
{currentTrack && ( {currentTrack && (
<div className="queue-current-track" style={{ position: 'relative' }}> <div className="queue-current-track">
{(currentTrack.bitRate || currentTrack.suffix) && ( {(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate) && (
<div className="queue-current-tech"> <div className="queue-current-tech">
{currentTrack.suffix?.toUpperCase() ?? ''} {[
{currentTrack.bitRate ? ` · ${currentTrack.bitRate}` : ''} currentTrack.genre,
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
].filter(Boolean).join(' · ')}
</div> </div>
)} )}
<div className="queue-current-cover"> <div className="queue-current-track-body">
{currentTrack.coverArt ? ( <div className="queue-current-cover">
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" /> {currentTrack.coverArt ? (
) : ( <img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
<div className="fallback"><Music size={32} /></div> ) : (
)} <div className="fallback"><Music size={32} /></div>
</div> )}
<div className="queue-current-info"> </div>
<h3 className="truncate">{currentTrack.title}</h3> <div className="queue-current-info">
<div <h3 className="truncate">{currentTrack.title}</h3>
className="queue-current-sub truncate" <div
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }} className="queue-current-sub truncate"
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
>{currentTrack.artist}</div> onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
<div >{currentTrack.artist}</div>
className="queue-current-sub truncate" <div
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }} className="queue-current-sub truncate"
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
>{currentTrack.album}</div> onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
{currentTrack.year && ( >{currentTrack.album}</div>
<div className="queue-current-sub">{currentTrack.year}</div> {currentTrack.year && (
)} <div className="queue-current-sub">{currentTrack.year}</div>
{renderStars(currentTrack.userRating)} )}
{renderStars(currentTrack.userRating)}
</div>
</div> </div>
</div> </div>
)} )}
@@ -524,7 +529,7 @@ export default function QueuePanel() {
const tracks: Track[] = data.songs.map(s => ({ const tracks: Track[] = data.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})); }));
if (tracks.length > 0) { if (tracks.length > 0) {
clearQueue(); clearQueue();
+1 -1
View File
@@ -61,7 +61,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
export default function Sidebar({ export default function Sidebar({
isCollapsed = false, isCollapsed = false,
toggleCollapse toggleCollapse,
}: { }: {
isCollapsed?: boolean; isCollapsed?: boolean;
toggleCollapse?: () => void; toggleCollapse?: () => void;
+2 -2
View File
@@ -67,7 +67,7 @@ export default function Favorites() {
const tracks = songs.map(s => ({ const tracks = songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})); }));
enqueue(tracks); enqueue(tracks);
}} }}
@@ -87,7 +87,7 @@ export default function Favorites() {
const track = { const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album, id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre,
}; };
return ( return (
<div <div
+1 -1
View File
@@ -63,7 +63,7 @@ export default function Playlists() {
id: s.id, title: s.title, artist: s.artist, album: s.album, id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year, coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})); }));
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); } if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
} catch (e) { console.error('Failed to play playlist', e); } } catch (e) { console.error('Failed to play playlist', e); }
+4 -4
View File
@@ -342,10 +342,10 @@ export default function RandomMix() {
{genreMixSongs.map(song => ( {genreMixSongs.map(song => (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }} <div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred }, 'song'); }} onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre }, 'song'); }}
onDragStart={e => { onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } })); e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre } }));
}} }}
> >
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}> <button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
@@ -391,7 +391,7 @@ export default function RandomMix() {
draggable draggable
onContextMenu={e => { onContextMenu={e => {
e.preventDefault(); e.preventDefault();
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred }; const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre };
setContextMenuSongId(song.id); setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, track, 'song'); openContextMenu(e.clientX, e.clientY, track, 'song');
}} }}
@@ -400,7 +400,7 @@ export default function RandomMix() {
const track = { const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album, id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre,
}; };
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}} }}
+3 -3
View File
@@ -35,12 +35,12 @@ export default function SearchResults() {
id: song.id, title: song.title, artist: song.artist, album: song.album, id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating, suffix: song.suffix, userRating: song.userRating, genre: song.genre,
}, list.map(s => ({ }, list.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate,
suffix: s.suffix, userRating: s.userRating, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}))); })));
}; };
@@ -99,7 +99,7 @@ export default function SearchResults() {
id: song.id, title: song.title, artist: song.artist, album: song.album, id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating, suffix: song.suffix, userRating: song.userRating, genre: song.genre,
}; };
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}} }}
+2
View File
@@ -24,6 +24,7 @@ export interface Track {
replayGainAlbumDb?: number; replayGainAlbumDb?: number;
replayGainPeak?: number; replayGainPeak?: number;
starred?: string; starred?: string;
genre?: string;
} }
export function songToTrack(song: SubsonicSong): Track { export function songToTrack(song: SubsonicSong): Track {
@@ -45,6 +46,7 @@ export function songToTrack(song: SubsonicSong): Track {
replayGainAlbumDb: song.replayGain?.albumGain, replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak, replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred, starred: song.starred,
genre: song.genre,
}; };
} }
+2 -4
View File
@@ -1629,14 +1629,12 @@
padding: 5px 8px; padding: 5px 8px;
border-radius: 6px; border-radius: 6px;
line-height: 1.6; line-height: 1.6;
transition: color 0.25s, font-size 0.2s, font-weight 0.2s; transition: color 0.25s;
cursor: default; cursor: default;
} }
.lyrics-line.active { .lyrics-line.active {
color: var(--text-primary); color: var(--accent);
font-size: 15px;
font-weight: 600;
} }
.lyrics-plain { .lyrics-plain {
+39 -18
View File
@@ -9,7 +9,6 @@
"sidebar main queue" "sidebar main queue"
"player player player"; "player player player";
height: 100vh; height: 100vh;
overflow: hidden;
background: var(--bg-app); background: var(--bg-app);
} }
@@ -39,6 +38,7 @@
background: var(--bg-sidebar); background: var(--bg-sidebar);
border-right: 1px solid var(--border-subtle); border-right: 1px solid var(--border-subtle);
position: relative; position: relative;
z-index: 2;
} }
.sidebar-brand { .sidebar-brand {
@@ -186,12 +186,12 @@
.collapse-btn { .collapse-btn {
position: absolute; position: absolute;
top: 50%; top: calc(50% - var(--player-height) / 2);
right: -13px; left: calc(var(--sidebar-width) - 11px);
transform: translateY(-50%); transform: translateY(-50%);
z-index: 101; z-index: 101;
width: 26px; width: 22px;
height: 26px; height: 22px;
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
background: var(--bg-card); background: var(--bg-card);
@@ -200,21 +200,36 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0; transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
transition: opacity 0.2s ease, background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.sidebar:hover .collapse-btn {
opacity: 1;
}
.collapse-btn:hover { .collapse-btn:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.queue-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
.queue-toggle-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ─── Update Toast ─── */ /* ─── Update Toast ─── */
@keyframes update-toast-in { @keyframes update-toast-in {
from { opacity: 0; transform: translateY(6px); } from { opacity: 0; transform: translateY(6px); }
@@ -749,13 +764,18 @@
} }
.queue-current-track { .queue-current-track {
display: flex;
flex-direction: column;
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.queue-current-track-body {
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;
gap: var(--space-3); gap: var(--space-3);
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
} }
.queue-current-cover { .queue-current-cover {
@@ -803,17 +823,18 @@
} }
.queue-current-tech { .queue-current-tech {
position: absolute; width: 100%;
top: 0;
right: 0;
font-size: 9px; font-size: 9px;
font-family: monospace; font-family: monospace;
letter-spacing: 0.05em; letter-spacing: 0.05em;
background: var(--ctp-surface1); background: var(--ctp-surface1);
color: var(--accent); color: var(--accent);
padding: 2px 6px; padding: 3px var(--space-4);
border-radius: 0 0 0 var(--radius-sm);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
backdrop-filter: blur(8px);
} }
.queue-divider { .queue-divider {