Compare commits

...

4 Commits

Author SHA1 Message Date
Psychotoxical d62bffd082 feat: v1.16.0 — 15 New Themes, W98 Overhaul, Aqua Quartz Polish
New themes: Aqua Quartz (Mac OS X Aqua, skeuomorphic), Spider-Tech,
T-800, B-Runner, Hill Valley 85, TetraStack, Turtle Power, Insta,
ReadIt, The Book (new Social Media group), W3.1, Jayfin (Jellyfin).

W98 rebuilt from scratch: authentic #d4d0c8 warm-gray, full 4-layer
3D bevel on all panels/buttons (raised/sunken on press), title bar
gradient on song name, navy progress fill, 16px styled scrollbar.

Aqua Quartz: all button variants now jelly-styled, blue Source List
sidebar with white pill nav, aluminium pinstripe background.

Theme Picker: groups and themes sorted alphabetically, Mediaplayer
group renamed, Pandora/Order of the Phoenix/Imperial Sith removed.

Fix: AlbumDetail genre propagation, W98 accordion active state,
Aqua Quartz sidebar labels, W98 connection indicator contrast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:54:57 +01:00
Psychotoxical ff706104ab fix: propagate genre in AlbumDetail track constructions
Play All, Enqueue All, and single-song play in AlbumDetail were
building Track objects without genre/starred fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:57:17 +01:00
Psychotoxical 0abef4b266 docs: update README screenshot 2026-03-23 19:52:18 +01:00
Psychotoxical 3effad0830 feat: v1.15.0 — Genre Strip, Lyrics Accent, Sidebar Button fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:47:32 +01:00
26 changed files with 1883 additions and 498 deletions
+51
View File
@@ -5,6 +5,57 @@ 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.16.0] - 2026-03-24
### Added
- **15 New Themes** across multiple categories:
- *Operating Systems*: **Aqua Quartz** — Mac OS X Aqua (skeuomorphic jelly buttons, brushed aluminium player bar, pinstripe background, blue Source List sidebar, authentic `#3876f7` accent)
- *Movies*: **Spider-Tech** (Spider-Man navy/red), **T-800** (Terminator Skynet blue), **B-Runner** (Blade Runner 2049 amber), **Hill Valley 85** (Back to the Future)
- *Games*: **TetraStack** (Tetris 8-bit, cyan, grid background, 0px radii)
- *Series*: **Turtle Power** (TMNT turtle green, brick tile sidebar)
- *Social Media* (new group): **Insta** (Instagram dark pink), **ReadIt** (Reddit dark orange-red), **The Book** (Facebook light, blue sidebar)
- *Operating Systems*: **W3.1** (Windows 3.1, light silver/teal, 0px radii, inset bevels)
- *Mediaplayer*: **Jayfin** (Jellyfin-inspired — deep black, purple `#AA5CC3` primary, cyan `#00A4DC` secondary, brand gradient on player bar and progress fill)
- **Aqua Quartz — Full Skeuomorphic Polish**: All button variants (`.btn-surface`, `.btn-ghost`, `.hero-play-btn`, `.album-card-details-btn`, `.queue-round-btn`) now have the authentic Aqua jelly gradient. Sidebar sports the iconic blue Source List gradient with white icons and a white pill for the active nav link.
### Changed
- **W98 Theme — Complete Overhaul**: Rebuilt from scratch with authentic Windows 98 design language: correct `#d4d0c8` warm-gray button face (not flat `#c0c0c0`), full 4-layer 3D bevel on all panels and buttons (raised default, sunken on press), song title displays in the iconic navy→light-blue title bar gradient, progress bar is a sunken white trough with navy fill, 16px styled scrollbar, all hover/active states consistently navy `#000080` + white text.
- **Theme Picker — Alphabetical Order**: All theme groups and themes within groups are now sorted alphabetically.
- **Theme Picker — Group Rename**: "Psysonic Themes — Mediaplayer" renamed to "Mediaplayer".
- **Sidebar + Queue Toggle Buttons**: Queue toggle button now uses the theme accent color (icon + hover).
### Fixed
- **AlbumDetail — Genre not propagating**: Playing via the album detail Play All / Enqueue All buttons now correctly includes the track genre in the constructed Track objects, making it show up in the Queue strip.
- **W98 — Theme Accordion active state**: Open category headers are now navy with white text instead of black-on-navy.
- **Aqua Quartz — Sidebar section labels**: "Library" / "System" labels now render in white on the blue sidebar.
- **W98 — Connection indicators**: Server name and Last.fm username in the header are now black (`#000000`) on the warm-gray background for full readability.
### Removed
- **Themes**: Removed **Pandora**, **Order of the Phoenix**, and **Imperial Sith** — too similar to other better-executed themes in their respective groups.
---
## [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
### Critical Fixes
+22 -10
View File
@@ -50,7 +50,7 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (100ms), `audio:ended`, `audio:error` events. `MASTER_HEADROOM` (-1 dB) prevents inter-sample clipping at full volume. |
| `src/store/themeStore.ts` | Theme selection (47 themes across 7 groups), applied as `data-theme` on `<html>` |
| `src/store/themeStore.ts` | Theme selection (62 themes across 8 groups), applied as `data-theme` on `<html>` |
| `src/store/lyricsStore.ts` | Sidebar tab state (`activeTab: 'queue' \| 'lyrics'`). `showLyrics()` / `showQueue()` / `setTab()`. Not persisted. |
| `src/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. |
| `src/api/lrclib.ts` | Fetches lyrics from `https://lrclib.net/api/get`. Returns `{ syncedLyrics, plainLyrics }`. `parseLrc()` parses LRC timestamps into sorted `LrcLine[]`. |
@@ -113,7 +113,7 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### Themes
47 themes across 7 groups, selectable in Settings via `ThemePicker`. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
62 themes across 8 groups, selectable in Settings via `ThemePicker`. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
@@ -124,13 +124,13 @@ Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper
| `psychowave` | Psysonic Themes | deep violet synthwave | Purple `#a06ae0` |
| `vintage-tube-radio` | Psysonic Themes | warm brown, VFD orange | Orange `#FF6F00` |
| `neon-drift` | Psysonic Themes | midnight blue, electric cyan glow | Cyan `#00f2ff` |
| `wnamp` | Psysonic — Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
| `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `spotless` | Psysonic — Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Psysonic — Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Psysonic — Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
| `wnamp` | Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
| `navy-jukebox` | Mediaplayer | silver/blue light | Blue `#0070a0` |
| `cobalt-media` | Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `onyx-cinema` | Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `spotless` | Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` |
@@ -141,17 +141,29 @@ Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
| `tetrastack` | Games | Tetris 8-bit, grid background | Cyan `#00f0f0` |
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
| `b-runner` | Movies | Blade Runner 2049, amber neon | Amber `#ff9500` |
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
| `dune` | Movies | Arrakis desert stone, spice orange | Orange `#ff8c00` |
| `hill-valley-85` | Movies | Back to the Future, brushed metal | Orange `#ff9900` |
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
| `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` |
| `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` |
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
| `spider-tech` | Movies | Spider-Man navy blue, hero red | Red `#E62429` |
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
| `t-800` | Movies | Terminator, Skynet blue | Cyan `#00d4ff` |
| `aqua-quartz` | Operating Systems | Mac OS X Aqua, skeuomorphic jelly buttons | Blue `#3876f7` |
| `w3-1` | Operating Systems | Windows 3.1, light silver/teal | Navy `#000080` |
| `ice-and-fire` | Series | Game of Thrones | Ice Blue `#70a1ff` |
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
| `heisenberg` | Series | Breaking Bad | Crystal Blue `#3fe0ff` |
| `turtle-power` | Series | TMNT, turtle green, brick sidebar | Green `#33cc33` |
| `insta` | Social Media | Instagram dark, pink gradient | Pink `#E1306C` |
| `readit` | Social Media | Reddit dark, orange-red | OrangeRed `#FF4500` |
| `the-book` | Social Media | Facebook light, blue sidebar | Blue `#1877F2` |
| `mocha` | Open Source Classics | Catppuccin dark | Mauve |
| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve |
| `frappe` | Open Source Classics | Catppuccin medium | Mauve |
@@ -246,4 +258,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`).
- **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.
- **Version**: 1.14.0
- **Version**: 1.16.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.14.0",
"version": "1.16.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.14.0
pkgver=1.16.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

+1 -1
View File
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.14.0"
version = "1.16.0"
dependencies = [
"biquad",
"md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.14.0"
version = "1.16.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.14.0",
"version": "1.16.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+5 -5
View File
@@ -135,9 +135,9 @@ function AppShell() {
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
<main className="main-content">
<header className="content-header">
@@ -147,12 +147,12 @@ function AppShell() {
<LastfmIndicator />
<NowPlayingDropdown />
<button
className="collapse-btn"
className="queue-toggle-btn"
onClick={toggleQueue}
data-tooltip={t('player.toggleQueue')}
data-tooltip-pos="bottom"
>
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
</header>
<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,
coverArt: song.coverArt, track: song.track, year: song.year,
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred,
starred: song.starred, genre: song.genre,
});
return (
+2 -2
View File
@@ -69,7 +69,7 @@ export default function ContextMenu() {
const radioTracks = [...top, ...similar].map(s => ({
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,
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);
}
@@ -135,7 +135,7 @@ export default function ContextMenu() {
const tracks = albumData.songs.map(s => ({
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,
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);
})}>
+1 -1
View File
@@ -153,7 +153,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const tracks = albumData.songs.map(s => ({
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,
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);
} 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.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
...(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('');
}}))),
] : [];
@@ -176,7 +176,7 @@ export default function LiveSearch() {
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
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('');
}}
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 => ({
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,
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);
}
@@ -318,36 +318,41 @@ export default function QueuePanel() {
</div>
{currentTrack && (
<div className="queue-current-track" style={{ position: 'relative' }}>
{(currentTrack.bitRate || currentTrack.suffix) && (
<div className="queue-current-track">
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate) && (
<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 className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{renderStars(currentTrack.userRating)}
<div className="queue-current-track-body">
<div className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{renderStars(currentTrack.userRating)}
</div>
</div>
</div>
)}
@@ -524,7 +529,7 @@ export default function QueuePanel() {
const tracks: Track[] = data.songs.map(s => ({
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,
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) {
clearQueue();
+1 -1
View File
@@ -61,7 +61,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
export default function Sidebar({
isCollapsed = false,
toggleCollapse
toggleCollapse,
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
+19 -4
View File
@@ -18,19 +18,22 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
{ id: 'tetrastack', label: 'TetraStack', bg: '#0a0a0a', card: '#151515', accent: '#00f0f0' },
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
],
},
{
group: 'Movies',
themes: [
{ id: 'b-runner', label: 'B-Runner', bg: '#141211', card: '#080707', accent: '#ff9500' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'imperial-sith', label: 'Imperial Sith', bg: '#0f0f11', card: '#050505', accent: '#e60000' },
{ id: 'dune', label: 'Dune', bg: '#1c1917', card: '#0c0a09', accent: '#ff8c00' },
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#1a1d23', card: '#0a0a0c', accent: '#ff9900' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'order-of-the-phoenix', label: 'Order of the Phoenix', bg: '#181818', card: '#0a0a0a', accent: '#e63900' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0F172A', card: '#050C1A', accent: '#E62429' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
{ id: 't-800', label: 'T-800', bg: '#1f242d', card: '#0a0c10', accent: '#00d4ff' },
],
},
{
@@ -55,8 +58,10 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Operating Systems',
themes: [
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'w3-1', label: 'W3.1', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
{ id: 'aero-glass', label: 'W7', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
@@ -73,7 +78,7 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
],
},
{
group: 'Psysonic Themes — Mediaplayer',
group: 'Mediaplayer',
themes: [
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
@@ -81,6 +86,7 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
],
},
@@ -90,6 +96,15 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#121820', card: '#05070a', accent: '#70a1ff' },
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#1a1d1a', card: '#0a0c0a', accent: '#3fe0ff' },
{ id: 'turtle-power', label: 'Turtle Power', bg: '#1a1a1a', card: '#0a0a0a', accent: '#33cc33' },
],
},
{
group: 'Social Media',
themes: [
{ id: 'insta', label: 'Insta', bg: '#121212', card: '#000000', accent: '#E1306C' },
{ id: 'readit', label: 'ReadIt', bg: '#030303', card: '#1A1A1B', accent: '#FF4500' },
{ id: 'the-book', label: 'The Book', bg: '#F0F2F5', card: '#FFFFFF', accent: '#1877F2' },
],
},
];
+3
View File
@@ -70,6 +70,7 @@ export default function AlbumDetail() {
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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
starred: s.starred, genre: s.genre,
}));
if (tracks[0]) playTrack(tracks[0], tracks);
};
@@ -80,6 +81,7 @@ export default function AlbumDetail() {
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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
starred: s.starred, genre: s.genre,
}));
enqueue(tracks);
};
@@ -89,6 +91,7 @@ export default function AlbumDetail() {
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,
};
playTrack(track, [track]);
};
+2 -2
View File
@@ -67,7 +67,7 @@ export default function Favorites() {
const tracks = songs.map(s => ({
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, 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);
}}
@@ -87,7 +87,7 @@ export default function Favorites() {
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,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre,
};
return (
<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,
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,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
} catch (e) { console.error('Failed to play playlist', e); }
+4 -4
View File
@@ -342,10 +342,10 @@ export default function RandomMix() {
{genreMixSongs.map(song => (
<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
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 => {
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); }}>
@@ -391,7 +391,7 @@ export default function RandomMix() {
draggable
onContextMenu={e => {
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);
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
@@ -400,7 +400,7 @@ export default function RandomMix() {
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,
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 }));
}}
+3 -3
View File
@@ -35,12 +35,12 @@ export default function SearchResults() {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
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 => ({
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,
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,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
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 }));
}}
+2
View File
@@ -24,6 +24,7 @@ export interface Track {
replayGainAlbumDb?: number;
replayGainPeak?: number;
starred?: string;
genre?: string;
}
export function songToTrack(song: SubsonicSong): Track {
@@ -45,6 +46,7 @@ export function songToTrack(song: SubsonicSong): Track {
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred,
genre: song.genre,
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'azerothian-gold' | 'ascalon' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade' | 'imperial-sith' | 'order-of-the-phoenix' | 'heisenberg' | 'ice-and-fire' | 'doh-matic';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'azerothian-gold' | 'ascalon' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'b-runner' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'jayfin';
interface ThemeState {
theme: Theme;
+2 -4
View File
@@ -1629,14 +1629,12 @@
padding: 5px 8px;
border-radius: 6px;
line-height: 1.6;
transition: color 0.25s, font-size 0.2s, font-weight 0.2s;
transition: color 0.25s;
cursor: default;
}
.lyrics-line.active {
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
color: var(--accent);
}
.lyrics-plain {
+39 -18
View File
@@ -9,7 +9,6 @@
"sidebar main queue"
"player player player";
height: 100vh;
overflow: hidden;
background: var(--bg-app);
}
@@ -39,6 +38,7 @@
background: var(--bg-sidebar);
border-right: 1px solid var(--border-subtle);
position: relative;
z-index: 2;
}
.sidebar-brand {
@@ -186,12 +186,12 @@
.collapse-btn {
position: absolute;
top: 50%;
right: -13px;
top: calc(50% - var(--player-height) / 2);
left: calc(var(--sidebar-width) - 11px);
transform: translateY(-50%);
z-index: 101;
width: 26px;
height: 26px;
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--border-subtle);
background: var(--bg-card);
@@ -200,21 +200,36 @@
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease, background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
box-shadow: var(--shadow-sm);
}
.sidebar:hover .collapse-btn {
opacity: 1;
}
.collapse-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
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(--accent);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
.queue-toggle-btn:hover {
background: var(--accent-dim);
color: var(--accent);
}
/* ─── Update Toast ─── */
@keyframes update-toast-in {
from { opacity: 0; transform: translateY(6px); }
@@ -749,13 +764,18 @@
}
.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);
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--space-3);
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.queue-current-cover {
@@ -803,17 +823,18 @@
}
.queue-current-tech {
position: absolute;
top: 0;
right: 0;
width: 100%;
font-size: 9px;
font-family: monospace;
letter-spacing: 0.05em;
background: var(--ctp-surface1);
color: var(--accent);
padding: 2px 6px;
border-radius: 0 0 0 var(--radius-sm);
padding: 3px var(--space-4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
backdrop-filter: blur(8px);
}
.queue-divider {
+1683 -405
View File
File diff suppressed because it is too large Load Diff