mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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,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,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
@@ -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' }}>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
})}>
|
})}>
|
||||||
|
|||||||
@@ -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 (_) { }
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user