Compare commits
5 Commits
v1.14.0
...
app-v1.17.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bdd6f3a59 | |||
| d62bffd082 | |||
| ff706104ab | |||
| 0abef4b266 | |||
| 3effad0830 |
@@ -5,6 +5,86 @@ 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.17.0] - 2026-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Media Keys & OS Media Controls** *(experimental)*: Initial integration via [souvlaki](https://github.com/Sinono3/souvlaki) — MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows. Track metadata (title, artist, album, cover art) and playback state are pushed to the OS media overlay in real time. On Linux, init is skipped gracefully if no D-Bus session is present. This feature is still under active development and observation — behaviour may vary across desktop environments and OS versions.
|
||||||
|
- **Random Mix — Artist Blacklist**: Artist names are now included in the keyword blacklist filter. Clickable artist chips in the tracklist let you add an artist to the blacklist with one click — same UX as the existing genre chips.
|
||||||
|
- **Favorites — Remove Song**: Each song row in Favorites now has an inline X button to remove the track from favorites instantly (optimistic UI, server unstar happens in the background).
|
||||||
|
- **3 New Themes**:
|
||||||
|
- *Games*: **Horde** — Durotar blood-red earth, iron-plate sidebar, forge-fire gold glow on track name.
|
||||||
|
- *Games*: **Alliance** — Stormwind deep navy, cathedral stone columns, paladin holy-light glow, gold sidebar trim and nav accent.
|
||||||
|
- *Operating Systems*: **W11** — Windows 11 Fluent Design dark mode. Mica-style sidebar, clean neutral palette, taskbar-inspired player bar. No gradients — faithful to the minimal Fluent aesthetic.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Theme renames**: Cobalt Media → **WinMedPlayer**, Onyx Cinema → **P-DVD**, Navy Jukebox → **MuMa Jukebox**.
|
||||||
|
- **NowPlayingDropdown**: Username / player name row now uses `--text-secondary` for improved readability across all themes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Performance — App-wide interaction lag**: Removed `[data-theme='X'] * { font-family: ... !important }` universal selectors from several themes (DOS, Unix, and others). The browser places universal selectors in the "universal bucket" and checks them against every DOM node on every style recalculation — measurably sluggish with 500–1000+ elements even when the affected theme is not active. `font-family` is now set on the theme root block (inherits to children) with a targeted `button, input, textarea, select` override for elements that don't inherit font.
|
||||||
|
- **Performance — Scroll jank**: Removed `repeating-linear-gradient` / `repeating-radial-gradient` from `.app-shell` in DOS, Unix, GW1, Morpheus, Aqua Quartz, and others. WebKitGTK with `WEBKIT_DISABLE_COMPOSITING_MODE=1` (always set by the AUR wrapper) has no GPU compositing — fine-pitch repeating patterns on the full-viewport background re-rasterize every scroll frame. Patterns are now applied only to `.sidebar` and `.player-bar`, which never scroll.
|
||||||
|
- **Contrast — 29 themes**: Audited all themes against WCAG AA. Fixed `--text-muted` and `--text-secondary` values in 29 themes that had insufficient contrast ratios (< 3.5:1). Affects Catppuccin (all four variants), Gruvbox (all six), Nord variants, GW1, Heisenberg, Ice and Fire, Spider-Tech, Morpheus, Hill Valley 85, Dune, and others.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Theme**: Azerothian Gold removed from the Games group.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
## [1.14.0] - 2026-03-22
|
||||||
|
|
||||||
### Critical Fixes
|
### Critical Fixes
|
||||||
|
|||||||
@@ -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/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/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-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 (60 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/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/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[]`. |
|
| `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`.
|
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
|
||||||
|
|
||||||
### Themes
|
### 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.
|
60 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).
|
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
|
||||||
|
|
||||||
@@ -124,34 +124,49 @@ Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper
|
|||||||
| `psychowave` | Psysonic Themes | deep violet synthwave | Purple `#a06ae0` |
|
| `psychowave` | Psysonic Themes | deep violet synthwave | Purple `#a06ae0` |
|
||||||
| `vintage-tube-radio` | Psysonic Themes | warm brown, VFD orange | Orange `#FF6F00` |
|
| `vintage-tube-radio` | Psysonic Themes | warm brown, VFD orange | Orange `#FF6F00` |
|
||||||
| `neon-drift` | Psysonic Themes | midnight blue, electric cyan glow | Cyan `#00f2ff` |
|
| `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` |
|
| `wnamp` | Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
|
||||||
| `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
|
| `muma-jukebox` | Mediaplayer | silver/blue light | Blue `#0070a0` |
|
||||||
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
|
| `winmedplayer` | Mediaplayer | cobalt blue dark | Lime `#45ff00` |
|
||||||
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
|
| `p-dvd` | Mediaplayer | near-black cinematic | Cyan `#00aaff` |
|
||||||
| `spotless` | Psysonic — Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
|
| `spotless` | Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
|
||||||
| `dzr0` | Psysonic — Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
|
| `dzr0` | Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
|
||||||
| `cupertino-beats` | Psysonic — Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
|
| `cupertino-beats` | Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
|
||||||
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
|
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
|
||||||
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
|
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
|
||||||
| `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` |
|
| `aero-glass` | Operating Systems | Win7 Aero — ice-blue glass sidebar, near-black frosted taskbar player bar, Aero button gradients | Blue `#1878e8` |
|
||||||
| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
|
| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
|
||||||
| `luna-teal` | Operating Systems | WinXP Luna, green gel buttons | Green `#3c9d29` |
|
| `luna-teal` | Operating Systems | WinXP Luna — warm tan bg, Luna blue task-pane sidebar, XP selection blue hover `#316AC5`, gel buttons | Green `#3c9d29` |
|
||||||
| `ascalon` | Games | Guild Wars 1 dark stone fantasy | Gold `#d4af37` |
|
| `w11` | Operating Systems | Windows 11 Fluent Design dark — Mica sidebar, clean neutrals, no gradients | Windows Blue `#0078d4` |
|
||||||
| `azerothian-gold` | Games | World of Warcraft | Gold `#c19e67` |
|
| `gw1` | Games | Guild Wars 1 — aged Tyrian stone, ornate gold borders, Cinzel serif track name, Searing crimson danger | Gold `#c8960c` |
|
||||||
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
|
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
|
||||||
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
|
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
|
||||||
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
|
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
|
||||||
|
| `tetrastack` | Games | Tetris 8-bit, grid background | Cyan `#00f0f0` |
|
||||||
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
|
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
|
||||||
|
| `horde` | Games | WoW Horde — Durotar blood-red earth, forge-fire gold glow, iron-plate sidebar | Blood Red `#cc2200` |
|
||||||
|
| `alliance` | Games | WoW Alliance — Stormwind deep navy, cathedral stone, paladin holy-light glow, gold sidebar trim | Royal Blue `#3388cc` |
|
||||||
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
|
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
|
||||||
|
| `dune` | Movies | Arrakis — warm sand main vs cool sietch-blue `#0e0c1a` sidebar, spice cinnamon `#c8780a` accent, desert horizon gradient, sand-grain scrollbar | Spice `#c8780a` |
|
||||||
|
| `hill-valley-85` | Movies | BTTF — DeLorean time circuit: track name red `#ff2200` Courier New uppercase, artist amber, stainless-steel sidebar lines, fire+lightning radial gradients | Orange `#ff8c00` |
|
||||||
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
|
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
|
||||||
| `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` |
|
| `middle-earth` | Movies | LOTR — parchment bg, Bag End oak wood-grain sidebar, `ring-inscription-glow` 5s animation on track name, Sammath Naur player bar, seven-stop gold progress bar, Georgia serif | Gold `#d4a820` |
|
||||||
| `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` |
|
| `morpheus` | Movies | Matrix — CRT scanlines on app-shell, vertical code-column sidebar pattern, terminal buttons (monospace, no fill, green glow border-radius 0), 6px scrollbar | Phosphor Green `#00ff41` |
|
||||||
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
|
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
|
||||||
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
|
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
|
||||||
|
| `spider-tech` | Movies | Spider-Man — CSS spider web (conic+radial) radiating from sidebar top-left, Into the Spider-Verse halftone dots on app-shell, track name red glow, artist in suit-blue `#4a88ff` | Red `#E62429` |
|
||||||
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
|
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
|
||||||
| `ice-and-fire` | Series | Game of Thrones | Ice Blue `#70a1ff` |
|
| `t-800` | Movies | Terminator, Skynet blue | Cyan `#00d4ff` |
|
||||||
|
| `aqua-quartz` | Operating Systems | Mac OS X Aqua, skeuomorphic jelly buttons | Blue `#3876f7` |
|
||||||
|
| `dos` | Operating Systems | MS-DOS — ANSI blue `#0000AA` bg, Courier New everywhere, inverted grey selection, blinking block cursor on track name, 16px DOS scrollbar | Yellow `#FFFF55` |
|
||||||
|
| `unix` | Operating Systems | Unix Shell — pure black, Courier New, `$ ` prompt prefix + blinking `_` cursor on track name, artist in ANSI blue `#5588FF` (ls directory color), thin 6px scrollbar | Green `#22C55E` |
|
||||||
|
| `w3-1` | Operating Systems | Windows 3.1, light silver/teal | Navy `#000080` |
|
||||||
|
| `ice-and-fire` | Series | Game of Thrones — temperature duality: volcanic warm main (`#100c08`) vs cold Castle Black sidebar (`#090c10`), `fire-flicker` 5s irregular animation on track name, dragon gold `#c8880a` artist, red-to-gold content header rule, forge fire glow on player bar | Blood Red `#c41e1e` |
|
||||||
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
|
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
|
||||||
| `heisenberg` | Series | Breaking Bad | Crystal Blue `#3fe0ff` |
|
| `heisenberg` | Series | Breaking Bad — periodic table grid on app-shell, hazmat tape diagonal on sidebar, `crystal-pulse` 3s glow animation on track name, lab fluorescent blue top border on player bar | Crystal Blue `#35d4f8` |
|
||||||
|
| `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 |
|
| `mocha` | Open Source Classics | Catppuccin dark | Mauve |
|
||||||
| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve |
|
| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve |
|
||||||
| `frappe` | Open Source Classics | Catppuccin medium | Mauve |
|
| `frappe` | Open Source Classics | Catppuccin medium | Mauve |
|
||||||
@@ -246,4 +261,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.16.0
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 🎨 **Gorgeous UI**: 47 deeply integrated themes across 7 groups — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Psysonic Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
||||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
|
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
|
||||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||||
@@ -55,7 +55,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
- [x] Multi-server support
|
- [x] Multi-server support
|
||||||
- [x] IndexedDB image caching
|
- [x] IndexedDB image caching
|
||||||
- [x] Random Mix with keyword filter & Super Genre mix
|
- [x] Random Mix with keyword filter & Super Genre mix
|
||||||
- [x] 47 themes across 7 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Psysonic originals, Psysonic Mediaplayer
|
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||||
- [x] Internationalization (English, German, French, Dutch)
|
- [x] Internationalization (English, German, French, Dutch)
|
||||||
- [x] AUR package (Arch / CachyOS)
|
- [x] AUR package (Arch / CachyOS)
|
||||||
- [x] Configurable keybindings
|
- [x] Configurable keybindings
|
||||||
@@ -63,6 +63,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
|
|
||||||
### 📋 Planned
|
### 📋 Planned
|
||||||
- [ ] General UI polish & visual refinement
|
- [ ] General UI polish & visual refinement
|
||||||
|
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
|
||||||
- [ ] More languages
|
- [ ] More languages
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.14.0",
|
"version": "1.17.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.17.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')
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 1.5 MiB |
Generated
+606
-52
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.14.0"
|
version = "1.16.0"
|
||||||
description = "Psysonic Desktop Music Player"
|
description = "Psysonic Desktop Music Player"
|
||||||
authors = []
|
authors = []
|
||||||
license = ""
|
license = ""
|
||||||
@@ -36,3 +36,4 @@ md5 = "0.7"
|
|||||||
tokio = { version = "1", features = ["rt", "time"] }
|
tokio = { version = "1", features = ["rt", "time"] }
|
||||||
biquad = "0.4"
|
biquad = "0.4"
|
||||||
tauri-plugin-window-state = "2.4.1"
|
tauri-plugin-window-state = "2.4.1"
|
||||||
|
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Psysonic is a music PLAYER only — no microphone access needed.
|
||||||
|
This suppresses the macOS microphone permission prompt triggered
|
||||||
|
by cpal/CoreAudio enumerating input devices during audio init. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Psysonic is a music player only — it does not record audio.
|
||||||
|
This description is shown if macOS prompts for microphone access
|
||||||
|
(triggered by CoreAudio enumerating input devices during init). -->
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+100
-18
@@ -16,6 +16,10 @@ use tauri::{
|
|||||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||||
type ShortcutMap = Mutex<HashMap<String, String>>;
|
type ShortcutMap = Mutex<HashMap<String, String>>;
|
||||||
|
|
||||||
|
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
|
||||||
|
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
|
||||||
|
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn greet(name: &str) -> String {
|
||||||
format!("Hello, {}!", name)
|
format!("Hello, {}!", name)
|
||||||
@@ -138,6 +142,52 @@ fn unregister_global_shortcut(
|
|||||||
app.global_shortcut().unregister(parsed).map_err(|e| e.to_string())
|
app.global_shortcut().unregister(parsed).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn mpris_set_metadata(
|
||||||
|
controls: tauri::State<MprisControls>,
|
||||||
|
title: Option<String>,
|
||||||
|
artist: Option<String>,
|
||||||
|
album: Option<String>,
|
||||||
|
cover_url: Option<String>,
|
||||||
|
duration_secs: Option<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use souvlaki::MediaMetadata;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let duration = duration_secs.map(|s| Duration::from_secs_f64(s));
|
||||||
|
let mut guard = controls.lock().unwrap();
|
||||||
|
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
|
||||||
|
ctrl.set_metadata(MediaMetadata {
|
||||||
|
title: title.as_deref(),
|
||||||
|
artist: artist.as_deref(),
|
||||||
|
album: album.as_deref(),
|
||||||
|
cover_url: cover_url.as_deref(),
|
||||||
|
duration,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn mpris_set_playback(
|
||||||
|
controls: tauri::State<MprisControls>,
|
||||||
|
playing: bool,
|
||||||
|
position_secs: Option<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use souvlaki::{MediaPlayback, MediaPosition};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let progress = position_secs.map(|s| MediaPosition(Duration::from_secs_f64(s)));
|
||||||
|
let playback = if playing {
|
||||||
|
MediaPlayback::Playing { progress }
|
||||||
|
} else {
|
||||||
|
MediaPlayback::Paused { progress }
|
||||||
|
};
|
||||||
|
let mut guard = controls.lock().unwrap();
|
||||||
|
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
|
||||||
|
ctrl.set_playback(playback)
|
||||||
|
.map_err(|e| format!("MPRIS set_playback failed: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let (audio_engine, _audio_thread) = audio::create_engine();
|
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||||
|
|
||||||
@@ -201,26 +251,56 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
|
||||||
// Register media key global shortcuts (all platforms)
|
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
||||||
{
|
{
|
||||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
||||||
let shortcuts = ["MediaPlayPause", "MediaNextTrack", "MediaPreviousTrack"];
|
|
||||||
for shortcut_str in &shortcuts {
|
// On Linux, souvlaki requires a live D-Bus session. If the env var
|
||||||
if let Ok(shortcut) = shortcut_str.parse::<Shortcut>() {
|
// is absent or empty (headless, test, or stripped environment),
|
||||||
let shortcut_clone = shortcut_str.to_string();
|
// skip init entirely — commands will no-op via the None branch.
|
||||||
let _ = app.global_shortcut().on_shortcut(shortcut, move |app, _shortcut, event| {
|
#[cfg(target_os = "linux")]
|
||||||
if event.state == ShortcutState::Pressed {
|
let dbus_ok = std::env::var("DBUS_SESSION_BUS_ADDRESS")
|
||||||
let event_name = match shortcut_clone.as_str() {
|
.map(|v| !v.is_empty())
|
||||||
"MediaPlayPause" => "media:play-pause",
|
.unwrap_or(false);
|
||||||
"MediaNextTrack" => "media:next",
|
#[cfg(not(target_os = "linux"))]
|
||||||
"MediaPreviousTrack" => "media:prev",
|
let dbus_ok = true;
|
||||||
_ => return,
|
|
||||||
};
|
if !dbus_ok {
|
||||||
let _ = app.emit(event_name, ());
|
eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled");
|
||||||
}
|
app.manage(MprisControls::new(None));
|
||||||
});
|
} else {
|
||||||
|
|
||||||
|
let config = PlatformConfig {
|
||||||
|
dbus_name: "psysonic",
|
||||||
|
display_name: "Psysonic",
|
||||||
|
hwnd: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let maybe_controls = match MediaControls::new(config) {
|
||||||
|
Ok(mut controls) => {
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||||
|
let event_name = match event {
|
||||||
|
MediaControlEvent::Toggle => "media:play-pause",
|
||||||
|
MediaControlEvent::Play => "media:play-pause",
|
||||||
|
MediaControlEvent::Pause => "media:play-pause",
|
||||||
|
MediaControlEvent::Next => "media:next",
|
||||||
|
MediaControlEvent::Previous => "media:prev",
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit(event_name, ());
|
||||||
|
}) {
|
||||||
|
eprintln!("[Psysonic] Failed to attach media controls: {e:?}");
|
||||||
|
}
|
||||||
|
Some(controls)
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
|
eprintln!("[Psysonic] Could not create media controls: {e:?}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
app.manage(MprisControls::new(maybe_controls));
|
||||||
|
} // end dbus_ok
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -239,6 +319,8 @@ pub fn run() {
|
|||||||
exit_app,
|
exit_app,
|
||||||
register_global_shortcut,
|
register_global_shortcut,
|
||||||
unregister_global_shortcut,
|
unregister_global_shortcut,
|
||||||
|
mpris_set_metadata,
|
||||||
|
mpris_set_playback,
|
||||||
audio::audio_play,
|
audio::audio_play,
|
||||||
audio::audio_pause,
|
audio::audio_pause,
|
||||||
audio::audio_resume,
|
audio::audio_resume,
|
||||||
|
|||||||
@@ -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.17.0",
|
||||||
"identifier": "dev.psysonic.player",
|
"identifier": "dev.psysonic.player",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -55,6 +55,10 @@
|
|||||||
"bundleMediaFramework": true
|
"bundleMediaFramework": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"macOS": {
|
||||||
|
"entitlements": "Entitlements.plist",
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
|
},
|
||||||
"windows": {
|
"windows": {
|
||||||
"wix": {
|
"wix": {
|
||||||
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
|
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
|
||||||
|
|||||||
+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}>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function NowPlayingDropdown() {
|
|||||||
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
<div className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</div>
|
<div className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</div>
|
||||||
<div className="truncate" style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{stream.artist}</div>
|
<div className="truncate" style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{stream.artist}</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-muted)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
|
||||||
<User size={10} />
|
<User size={10} />
|
||||||
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
|
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
|
||||||
{stream.minutesAgo > 0 && <span>• {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>}
|
{stream.minutesAgo > 0 && <span>• {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -13,24 +13,27 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
|||||||
{
|
{
|
||||||
group: 'Games',
|
group: 'Games',
|
||||||
themes: [
|
themes: [
|
||||||
{ id: 'ascalon', label: 'Ascalon', bg: '#1c1a17', card: '#0f0d0b', accent: '#d4af37' },
|
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
|
||||||
{ id: 'azerothian-gold', label: 'Azerothian Gold', bg: '#1a1a1a', card: '#0a0a0a', accent: '#c19e67' },
|
|
||||||
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
|
{ 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: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
|
||||||
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
|
{ 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' },
|
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
|
||||||
|
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
|
||||||
|
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Movies',
|
group: 'Movies',
|
||||||
themes: [
|
themes: [
|
||||||
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
|
{ 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: '#1c1408', card: '#0e0c1a', accent: '#c8780a' },
|
||||||
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
|
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#0d0b18', card: '#141120', accent: '#ff8c00' },
|
||||||
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
|
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f0e0b0', card: '#241a0e', accent: '#d4a820' },
|
||||||
{ id: 'order-of-the-phoenix', label: 'Order of the Phoenix', bg: '#181818', card: '#0a0a0a', accent: '#e63900' },
|
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
|
||||||
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
|
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
|
||||||
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
{ 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,11 +58,16 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
|||||||
{
|
{
|
||||||
group: 'Operating Systems',
|
group: 'Operating Systems',
|
||||||
themes: [
|
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-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
||||||
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
||||||
{ id: 'aero-glass', label: 'W7', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
|
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
|
||||||
{ id: 'w98', label: 'W98', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
|
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
|
||||||
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
|
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
|
||||||
|
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
|
||||||
|
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
|
||||||
|
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
|
||||||
|
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,23 +81,33 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Psysonic Themes — Mediaplayer',
|
group: 'Mediaplayer',
|
||||||
themes: [
|
themes: [
|
||||||
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
|
{ id: 'winmedplayer', label: 'WinMedPlayer', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
|
||||||
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
|
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
|
||||||
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
|
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
|
||||||
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
|
{ id: 'muma-jukebox', label: 'MuMa Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
|
||||||
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
||||||
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
|
{ 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' },
|
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Series',
|
group: 'Series',
|
||||||
themes: [
|
themes: [
|
||||||
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#121820', card: '#05070a', accent: '#70a1ff' },
|
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#100c08', card: '#090c10', accent: '#c41e1e' },
|
||||||
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
|
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
|
||||||
{ id: 'heisenberg', label: 'Heisenberg', bg: '#1a1d1a', card: '#0a0c0a', accent: '#3fe0ff' },
|
{ id: 'heisenberg', label: 'Heisenberg', bg: '#0b0e12', card: '#141a22', accent: '#35d4f8' },
|
||||||
|
{ 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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+28
-12
@@ -148,6 +148,7 @@ const enTranslation = {
|
|||||||
albums: 'Albums',
|
albums: 'Albums',
|
||||||
songs: 'Songs',
|
songs: 'Songs',
|
||||||
enqueueAll: 'Add all to queue',
|
enqueueAll: 'Add all to queue',
|
||||||
|
removeSong: 'Remove from favorites',
|
||||||
},
|
},
|
||||||
randomAlbums: {
|
randomAlbums: {
|
||||||
title: 'Random Albums',
|
title: 'Random Albums',
|
||||||
@@ -168,17 +169,20 @@ const enTranslation = {
|
|||||||
play: 'Play',
|
play: 'Play',
|
||||||
trackGenre: 'Genre',
|
trackGenre: 'Genre',
|
||||||
excludeAudiobooks: 'Exclude audiobooks & radio plays',
|
excludeAudiobooks: 'Exclude audiobooks & radio plays',
|
||||||
excludeAudiobooksDesc: 'Matches keywords against genre, title, and album — e.g. Hörbuch, Audiobook, Spoken Word, …',
|
excludeAudiobooksDesc: 'Matches keywords against genre, title, album, and artist — e.g. Hörbuch, Audiobook, Spoken Word, …',
|
||||||
genreBlocked: 'Keyword blocked',
|
genreBlocked: 'Keyword blocked',
|
||||||
genreAddedToBlacklist: 'Added to filter list',
|
genreAddedToBlacklist: 'Added to filter list',
|
||||||
genreAlreadyBlocked: 'Already blocked',
|
genreAlreadyBlocked: 'Already blocked',
|
||||||
|
artistBlocked: 'Artist blocked',
|
||||||
|
artistAddedToBlacklist: 'Artist added to filter list',
|
||||||
|
artistClickHint: 'Click to block this artist',
|
||||||
blacklistToggle: 'Keyword Filter',
|
blacklistToggle: 'Keyword Filter',
|
||||||
genreMixTitle: 'Genre Mix',
|
genreMixTitle: 'Genre Mix',
|
||||||
genreMixDesc: 'Select a genre to get a curated random mix',
|
genreMixDesc: 'Select a genre to get a curated random mix',
|
||||||
genreMixLoadMore: 'Load 10 more',
|
genreMixLoadMore: 'Load 10 more',
|
||||||
genreMixNoGenres: 'No genres found on server.',
|
genreMixNoGenres: 'No genres found on server.',
|
||||||
filterPanelTitle: 'Filters',
|
filterPanelTitle: 'Filters',
|
||||||
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title & album.',
|
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.',
|
||||||
},
|
},
|
||||||
playlists: {
|
playlists: {
|
||||||
title: 'Playlists',
|
title: 'Playlists',
|
||||||
@@ -341,7 +345,7 @@ const enTranslation = {
|
|||||||
changelog: 'Changelog',
|
changelog: 'Changelog',
|
||||||
randomMixTitle: 'Random Mix',
|
randomMixTitle: 'Random Mix',
|
||||||
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
||||||
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).',
|
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
|
||||||
randomMixBlacklistPlaceholder: 'Add keyword…',
|
randomMixBlacklistPlaceholder: 'Add keyword…',
|
||||||
randomMixBlacklistAdd: 'Add',
|
randomMixBlacklistAdd: 'Add',
|
||||||
randomMixBlacklistEmpty: 'No custom keywords added yet.',
|
randomMixBlacklistEmpty: 'No custom keywords added yet.',
|
||||||
@@ -691,6 +695,7 @@ const deTranslation = {
|
|||||||
albums: 'Alben',
|
albums: 'Alben',
|
||||||
songs: 'Songs',
|
songs: 'Songs',
|
||||||
enqueueAll: 'Alle in die Warteschlange',
|
enqueueAll: 'Alle in die Warteschlange',
|
||||||
|
removeSong: 'Aus Favoriten entfernen',
|
||||||
},
|
},
|
||||||
randomAlbums: {
|
randomAlbums: {
|
||||||
title: 'Zufallsalben',
|
title: 'Zufallsalben',
|
||||||
@@ -711,17 +716,20 @@ const deTranslation = {
|
|||||||
play: 'Abspielen',
|
play: 'Abspielen',
|
||||||
trackGenre: 'Genre',
|
trackGenre: 'Genre',
|
||||||
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
|
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
|
||||||
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel und Album — z. B. Hörbuch, Audiobook, Spoken Word, …',
|
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel, Album und Künstler — z. B. Hörbuch, Audiobook, Spoken Word, …',
|
||||||
genreBlocked: 'Keyword gesperrt',
|
genreBlocked: 'Keyword gesperrt',
|
||||||
genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
|
genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
|
||||||
genreAlreadyBlocked: 'Bereits gesperrt',
|
genreAlreadyBlocked: 'Bereits gesperrt',
|
||||||
|
artistBlocked: 'Künstler gesperrt',
|
||||||
|
artistAddedToBlacklist: 'Künstler zur Filterliste hinzugefügt',
|
||||||
|
artistClickHint: 'Klicken um diesen Künstler zu sperren',
|
||||||
blacklistToggle: 'Keyword-Filter',
|
blacklistToggle: 'Keyword-Filter',
|
||||||
genreMixTitle: 'Genre-Mix',
|
genreMixTitle: 'Genre-Mix',
|
||||||
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
|
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
|
||||||
genreMixLoadMore: '10 weitere laden',
|
genreMixLoadMore: '10 weitere laden',
|
||||||
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
|
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
|
||||||
filterPanelTitle: 'Filter',
|
filterPanelTitle: 'Filter',
|
||||||
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel & Album.',
|
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
|
||||||
},
|
},
|
||||||
playlists: {
|
playlists: {
|
||||||
title: 'Playlists',
|
title: 'Playlists',
|
||||||
@@ -884,7 +892,7 @@ const deTranslation = {
|
|||||||
changelog: 'Changelog',
|
changelog: 'Changelog',
|
||||||
randomMixTitle: 'Zufallsmix',
|
randomMixTitle: 'Zufallsmix',
|
||||||
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
||||||
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).',
|
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
|
||||||
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
|
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
|
||||||
randomMixBlacklistAdd: 'Hinzufügen',
|
randomMixBlacklistAdd: 'Hinzufügen',
|
||||||
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
|
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
|
||||||
@@ -1234,6 +1242,7 @@ const frTranslation = {
|
|||||||
albums: 'Albums',
|
albums: 'Albums',
|
||||||
songs: 'Morceaux',
|
songs: 'Morceaux',
|
||||||
enqueueAll: 'Tout ajouter à la file',
|
enqueueAll: 'Tout ajouter à la file',
|
||||||
|
removeSong: 'Retirer des favoris',
|
||||||
},
|
},
|
||||||
randomAlbums: {
|
randomAlbums: {
|
||||||
title: 'Albums aléatoires',
|
title: 'Albums aléatoires',
|
||||||
@@ -1254,17 +1263,20 @@ const frTranslation = {
|
|||||||
play: 'Lire',
|
play: 'Lire',
|
||||||
trackGenre: 'Genre',
|
trackGenre: 'Genre',
|
||||||
excludeAudiobooks: 'Exclure les livres audio et pièces radiophoniques',
|
excludeAudiobooks: 'Exclure les livres audio et pièces radiophoniques',
|
||||||
excludeAudiobooksDesc: 'Correspond aux mots-clés dans le genre, le titre et l\'album — ex. Hörbuch, Audiobook, Spoken Word, …',
|
excludeAudiobooksDesc: 'Correspond aux mots-clés dans le genre, le titre, l\'album et l\'artiste — ex. Hörbuch, Audiobook, Spoken Word, …',
|
||||||
genreBlocked: 'Mot-clé bloqué',
|
genreBlocked: 'Mot-clé bloqué',
|
||||||
genreAddedToBlacklist: 'Ajouté à la liste de filtres',
|
genreAddedToBlacklist: 'Ajouté à la liste de filtres',
|
||||||
genreAlreadyBlocked: 'Déjà bloqué',
|
genreAlreadyBlocked: 'Déjà bloqué',
|
||||||
|
artistBlocked: 'Artiste bloqué',
|
||||||
|
artistAddedToBlacklist: 'Artiste ajouté à la liste de filtres',
|
||||||
|
artistClickHint: 'Cliquer pour bloquer cet artiste',
|
||||||
blacklistToggle: 'Filtre par mot-clé',
|
blacklistToggle: 'Filtre par mot-clé',
|
||||||
genreMixTitle: 'Mix par genre',
|
genreMixTitle: 'Mix par genre',
|
||||||
genreMixDesc: 'Sélectionnez un genre pour obtenir un mix aléatoire',
|
genreMixDesc: 'Sélectionnez un genre pour obtenir un mix aléatoire',
|
||||||
genreMixLoadMore: 'Charger 10 de plus',
|
genreMixLoadMore: 'Charger 10 de plus',
|
||||||
genreMixNoGenres: 'Aucun genre trouvé sur le serveur.',
|
genreMixNoGenres: 'Aucun genre trouvé sur le serveur.',
|
||||||
filterPanelTitle: 'Filtres',
|
filterPanelTitle: 'Filtres',
|
||||||
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre et album.',
|
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.',
|
||||||
},
|
},
|
||||||
playlists: {
|
playlists: {
|
||||||
title: 'Listes de lecture',
|
title: 'Listes de lecture',
|
||||||
@@ -1427,7 +1439,7 @@ const frTranslation = {
|
|||||||
changelog: 'Journal des modifications',
|
changelog: 'Journal des modifications',
|
||||||
randomMixTitle: 'Mix aléatoire',
|
randomMixTitle: 'Mix aléatoire',
|
||||||
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
|
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
|
||||||
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre ou album (actif quand la case ci-dessus est cochée).',
|
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
|
||||||
randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
|
randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
|
||||||
randomMixBlacklistAdd: 'Ajouter',
|
randomMixBlacklistAdd: 'Ajouter',
|
||||||
randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.',
|
randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.',
|
||||||
@@ -1777,6 +1789,7 @@ const nlTranslation = {
|
|||||||
albums: 'Albums',
|
albums: 'Albums',
|
||||||
songs: 'Nummers',
|
songs: 'Nummers',
|
||||||
enqueueAll: 'Alles aan wachtrij toevoegen',
|
enqueueAll: 'Alles aan wachtrij toevoegen',
|
||||||
|
removeSong: 'Verwijderen uit favorieten',
|
||||||
},
|
},
|
||||||
randomAlbums: {
|
randomAlbums: {
|
||||||
title: 'Willekeurige albums',
|
title: 'Willekeurige albums',
|
||||||
@@ -1797,17 +1810,20 @@ const nlTranslation = {
|
|||||||
play: 'Afspelen',
|
play: 'Afspelen',
|
||||||
trackGenre: 'Genre',
|
trackGenre: 'Genre',
|
||||||
excludeAudiobooks: 'Luisterboeken en hoorspelen uitsluiten',
|
excludeAudiobooks: 'Luisterboeken en hoorspelen uitsluiten',
|
||||||
excludeAudiobooksDesc: 'Vergelijkt trefwoorden met genre, titel en album — bijv. Hörbuch, Audiobook, Spoken Word, …',
|
excludeAudiobooksDesc: 'Vergelijkt trefwoorden met genre, titel, album en artiest — bijv. Hörbuch, Audiobook, Spoken Word, …',
|
||||||
genreBlocked: 'Trefwoord geblokkeerd',
|
genreBlocked: 'Trefwoord geblokkeerd',
|
||||||
genreAddedToBlacklist: 'Aan filterlijst toegevoegd',
|
genreAddedToBlacklist: 'Aan filterlijst toegevoegd',
|
||||||
genreAlreadyBlocked: 'Al geblokkeerd',
|
genreAlreadyBlocked: 'Al geblokkeerd',
|
||||||
|
artistBlocked: 'Artiest geblokkeerd',
|
||||||
|
artistAddedToBlacklist: 'Artiest aan filterlijst toegevoegd',
|
||||||
|
artistClickHint: 'Klik om deze artiest te blokkeren',
|
||||||
blacklistToggle: 'Trefwoordfilter',
|
blacklistToggle: 'Trefwoordfilter',
|
||||||
genreMixTitle: 'Genremix',
|
genreMixTitle: 'Genremix',
|
||||||
genreMixDesc: 'Selecteer een genre voor een samengestelde willekeurige mix',
|
genreMixDesc: 'Selecteer een genre voor een samengestelde willekeurige mix',
|
||||||
genreMixLoadMore: '10 meer laden',
|
genreMixLoadMore: '10 meer laden',
|
||||||
genreMixNoGenres: 'Geen genres gevonden op server.',
|
genreMixNoGenres: 'Geen genres gevonden op server.',
|
||||||
filterPanelTitle: 'Filters',
|
filterPanelTitle: 'Filters',
|
||||||
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel & album.',
|
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
|
||||||
},
|
},
|
||||||
playlists: {
|
playlists: {
|
||||||
title: 'Afspeellijsten',
|
title: 'Afspeellijsten',
|
||||||
@@ -1970,7 +1986,7 @@ const nlTranslation = {
|
|||||||
changelog: 'Wijzigingslog',
|
changelog: 'Wijzigingslog',
|
||||||
randomMixTitle: 'Willekeurige mix',
|
randomMixTitle: 'Willekeurige mix',
|
||||||
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
|
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
|
||||||
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel of album (actief wanneer het selectievakje hierboven is aangevinkt).',
|
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
|
||||||
randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
|
randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
|
||||||
randomMixBlacklistAdd: 'Toevoegen',
|
randomMixBlacklistAdd: 'Toevoegen',
|
||||||
randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.',
|
randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.',
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export default function AlbumDetail() {
|
|||||||
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,
|
||||||
|
starred: s.starred, genre: s.genre,
|
||||||
}));
|
}));
|
||||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
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,
|
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,
|
||||||
|
starred: s.starred, genre: s.genre,
|
||||||
}));
|
}));
|
||||||
enqueue(tracks);
|
enqueue(tracks);
|
||||||
};
|
};
|
||||||
@@ -89,6 +91,7 @@ export default function AlbumDetail() {
|
|||||||
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,
|
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||||
|
starred: song.starred, genre: song.genre,
|
||||||
};
|
};
|
||||||
playTrack(track, [track]);
|
playTrack(track, [track]);
|
||||||
};
|
};
|
||||||
|
|||||||
+22
-4
@@ -3,9 +3,10 @@ import AlbumRow from '../components/AlbumRow';
|
|||||||
import ArtistRow from '../components/ArtistRow';
|
import ArtistRow from '../components/ArtistRow';
|
||||||
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { Play, ListPlus } from 'lucide-react';
|
import { ListPlus, X } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { unstar } from '../api/subsonic';
|
||||||
|
|
||||||
export default function Favorites() {
|
export default function Favorites() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -15,6 +16,11 @@ export default function Favorites() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const { playTrack, enqueue } = usePlayerStore();
|
const { playTrack, enqueue } = usePlayerStore();
|
||||||
|
|
||||||
|
function removeSong(id: string) {
|
||||||
|
unstar(id, 'song').catch(() => {});
|
||||||
|
setSongs(prev => prev.filter(s => s.id !== id));
|
||||||
|
}
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -67,7 +73,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);
|
||||||
}}
|
}}
|
||||||
@@ -77,22 +83,24 @@ export default function Favorites() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="tracklist" style={{ padding: 0 }}>
|
<div className="tracklist" style={{ padding: 0 }}>
|
||||||
<div className="tracklist-header tracklist-va">
|
<div className="tracklist-header tracklist-va" style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}>
|
||||||
<div className="col-center">#</div>
|
<div className="col-center">#</div>
|
||||||
<div>{t('albumDetail.trackTitle')}</div>
|
<div>{t('albumDetail.trackTitle')}</div>
|
||||||
<div>{t('albumDetail.trackArtist')}</div>
|
<div>{t('albumDetail.trackArtist')}</div>
|
||||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||||
|
<div />
|
||||||
</div>
|
</div>
|
||||||
{songs.map((song, i) => {
|
{songs.map((song, i) => {
|
||||||
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
|
||||||
key={song.id}
|
key={song.id}
|
||||||
className="track-row track-row-va"
|
className="track-row track-row-va"
|
||||||
|
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||||
onDoubleClick={() => playTrack(song, songs)}
|
onDoubleClick={() => playTrack(song, songs)}
|
||||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||||
role="row"
|
role="row"
|
||||||
@@ -118,6 +126,16 @@ export default function Favorites() {
|
|||||||
<div className="track-duration">
|
<div className="track-duration">
|
||||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
className="btn-icon fav-remove-btn"
|
||||||
|
data-tooltip={t('favorites.removeSong')}
|
||||||
|
onClick={e => { e.stopPropagation(); removeSong(song.id); }}
|
||||||
|
aria-label={t('favorites.removeSong')}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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); }
|
||||||
|
|||||||
+45
-5
@@ -48,6 +48,7 @@ export default function RandomMix() {
|
|||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
||||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||||
|
const [addedArtist, setAddedArtist] = useState<string | null>(null);
|
||||||
|
|
||||||
// Blacklist panel state
|
// Blacklist panel state
|
||||||
const [blacklistOpen, setBlacklistOpen] = useState(false);
|
const [blacklistOpen, setBlacklistOpen] = useState(false);
|
||||||
@@ -94,6 +95,7 @@ export default function RandomMix() {
|
|||||||
if (song.genre && checkText(song.genre)) return false;
|
if (song.genre && checkText(song.genre)) return false;
|
||||||
if (song.title && checkText(song.title)) return false;
|
if (song.title && checkText(song.title)) return false;
|
||||||
if (song.album && checkText(song.album)) return false;
|
if (song.album && checkText(song.album)) return false;
|
||||||
|
if (song.artist && checkText(song.artist)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -342,10 +344,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 +393,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 +402,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 }));
|
||||||
}}
|
}}
|
||||||
@@ -419,7 +421,45 @@ export default function RandomMix() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="track-artist-cell">
|
<div className="track-artist-cell">
|
||||||
<span className="track-artist">{song.artist}</span>
|
{(() => {
|
||||||
|
const artist = song.artist;
|
||||||
|
if (!artist) return <span className="track-artist">—</span>;
|
||||||
|
const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||||
|
const justAdded = addedArtist === artist;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost track-artist"
|
||||||
|
style={{
|
||||||
|
fontSize: 'inherit',
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',
|
||||||
|
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: isBlocked ? 'default' : 'pointer',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
height: 'auto',
|
||||||
|
minHeight: 'unset',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (isBlocked) return;
|
||||||
|
const already = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||||
|
if (!already) {
|
||||||
|
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||||
|
setAddedArtist(artist);
|
||||||
|
setTimeout(() => setAddedArtist(null), 1500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-tooltip={isBlocked ? t('randomMix.artistBlocked') : justAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||||
|
>
|
||||||
|
{artist}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="track-info">
|
<div className="track-info">
|
||||||
|
|||||||
@@ -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,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
|
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
|
||||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,8 +335,43 @@ export function initAudioListeners(): () => void {
|
|||||||
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
|
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── MPRIS / OS media controls sync ───────────────────────────────────────
|
||||||
|
// Whenever the current track or playback state changes, push updates to the
|
||||||
|
// Rust souvlaki MediaControls so the OS media overlay stays accurate.
|
||||||
|
let prevTrackId: string | null = null;
|
||||||
|
let prevIsPlaying: boolean | null = null;
|
||||||
|
|
||||||
|
const unsubMpris = usePlayerStore.subscribe((state) => {
|
||||||
|
const { currentTrack, isPlaying, currentTime } = state;
|
||||||
|
|
||||||
|
// Update metadata when track changes
|
||||||
|
if (currentTrack && currentTrack.id !== prevTrackId) {
|
||||||
|
prevTrackId = currentTrack.id;
|
||||||
|
const coverUrl = currentTrack.coverArt
|
||||||
|
? buildCoverArtUrl(currentTrack.coverArt, 512)
|
||||||
|
: undefined;
|
||||||
|
invoke('mpris_set_metadata', {
|
||||||
|
title: currentTrack.title,
|
||||||
|
artist: currentTrack.artist,
|
||||||
|
album: currentTrack.album,
|
||||||
|
coverUrl,
|
||||||
|
durationSecs: currentTrack.duration,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update playback state when it changes
|
||||||
|
if (isPlaying !== prevIsPlaying) {
|
||||||
|
prevIsPlaying = isPlaying;
|
||||||
|
invoke('mpris_set_playback', {
|
||||||
|
playing: isPlaying,
|
||||||
|
positionSecs: currentTime > 0 ? currentTime : null,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubAuth();
|
unsubAuth();
|
||||||
|
unsubMpris();
|
||||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
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' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | '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' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11';
|
||||||
|
|
||||||
interface ThemeState {
|
interface ThemeState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -2558,6 +2556,29 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fav-remove-btn {
|
||||||
|
opacity: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: opacity 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-remove-btn:hover {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row:hover .fav-remove-btn,
|
||||||
|
.track-row.context-active .fav-remove-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─ Statistics Page ─ */
|
/* ─ Statistics Page ─ */
|
||||||
.stats-page {
|
.stats-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+40
-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(--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 ─── */
|
/* ─── Update Toast ─── */
|
||||||
@keyframes update-toast-in {
|
@keyframes update-toast-in {
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
@@ -317,6 +332,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
|
contain: paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Player Bar ─── */
|
/* ─── Player Bar ─── */
|
||||||
@@ -749,13 +765,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 +824,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 {
|
||||||
|
|||||||
+5305
-1162
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user