feat: v1.17.0 — Media Keys, 3 New Themes, Perf Fixes, Contrast Audit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-25 21:30:13 +01:00
parent d62bffd082
commit 6bdd6f3a59
21 changed files with 4929 additions and 1232 deletions
+29
View File
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.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 5001000+ 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
+20 -17
View File
@@ -50,7 +50,7 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (100ms), `audio:ended`, `audio:error` events. `MASTER_HEADROOM` (-1 dB) prevents inter-sample clipping at full volume. |
| `src/store/themeStore.ts` | Theme selection (62 themes across 8 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/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. |
| `src/api/lrclib.ts` | Fetches lyrics from `https://lrclib.net/api/get`. Returns `{ syncedLyrics, plainLyrics }`. `parseLrc()` parses LRC timestamps into sorted `LrcLine[]`. |
@@ -113,7 +113,7 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### Themes
62 themes across 8 groups, selectable in Settings via `ThemePicker`. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
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).
@@ -125,41 +125,44 @@ Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper
| `vintage-tube-radio` | Psysonic Themes | warm brown, VFD orange | Orange `#FF6F00` |
| `neon-drift` | Psysonic Themes | midnight blue, electric cyan glow | Cyan `#00f2ff` |
| `wnamp` | Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
| `navy-jukebox` | Mediaplayer | silver/blue light | Blue `#0070a0` |
| `cobalt-media` | Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `onyx-cinema` | Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `muma-jukebox` | Mediaplayer | silver/blue light | Blue `#0070a0` |
| `winmedplayer` | Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `p-dvd` | Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `spotless` | Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` |
| `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` |
| `luna-teal` | Operating Systems | WinXP Luna, green gel buttons | Green `#3c9d29` |
| `ascalon` | Games | Guild Wars 1 dark stone fantasy | Gold `#d4af37` |
| `azerothian-gold` | Games | World of Warcraft | Gold `#c19e67` |
| `luna-teal` | Operating Systems | WinXP Luna — warm tan bg, Luna blue task-pane sidebar, XP selection blue hover `#316AC5`, gel buttons | Green `#3c9d29` |
| `w11` | Operating Systems | Windows 11 Fluent Design dark — Mica sidebar, clean neutrals, no gradients | Windows Blue `#0078d4` |
| `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` |
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
| `tetrastack` | Games | Tetris 8-bit, grid background | Cyan `#00f0f0` |
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
| `b-runner` | Movies | Blade Runner 2049, amber neon | Amber `#ff9500` |
| `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` |
| `dune` | Movies | Arrakis desert stone, spice orange | Orange `#ff8c00` |
| `hill-valley-85` | Movies | Back to the Future, brushed metal | Orange `#ff9900` |
| `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` |
| `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` |
| `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` |
| `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 — 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` |
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
| `spider-tech` | Movies | Spider-Man navy blue, hero red | Red `#E62429` |
| `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` |
| `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 | Ice Blue `#70a1ff` |
| `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` |
| `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` |
+3 -2
View File
@@ -22,7 +22,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ✨ 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.
- 🌍 **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.
@@ -55,7 +55,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] Multi-server support
- [x] IndexedDB image caching
- [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] AUR package (Arch / CachyOS)
- [x] Configurable keybindings
@@ -63,6 +63,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
### 📋 Planned
- [ ] General UI polish & visual refinement
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
- [ ] More languages
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.16.0",
"version": "1.17.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.16.0
pkgver=1.17.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+605 -51
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -36,3 +36,4 @@ md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
tauri-plugin-window-state = "2.4.1"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
+11
View File
@@ -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>
+11
View File
@@ -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>
+96 -14
View File
@@ -16,6 +16,10 @@ use tauri::{
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
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]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
@@ -138,6 +142,52 @@ fn unregister_global_shortcut(
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() {
let (audio_engine, _audio_thread) = audio::create_engine();
@@ -201,26 +251,56 @@ pub fn run() {
})
.build(app)?;
// Register media key global shortcuts (all platforms)
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
{
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
let shortcuts = ["MediaPlayPause", "MediaNextTrack", "MediaPreviousTrack"];
for shortcut_str in &shortcuts {
if let Ok(shortcut) = shortcut_str.parse::<Shortcut>() {
let shortcut_clone = shortcut_str.to_string();
let _ = app.global_shortcut().on_shortcut(shortcut, move |app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
let event_name = match shortcut_clone.as_str() {
"MediaPlayPause" => "media:play-pause",
"MediaNextTrack" => "media:next",
"MediaPreviousTrack" => "media:prev",
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
// On Linux, souvlaki requires a live D-Bus session. If the env var
// is absent or empty (headless, test, or stripped environment),
// skip init entirely — commands will no-op via the None branch.
#[cfg(target_os = "linux")]
let dbus_ok = std::env::var("DBUS_SESSION_BUS_ADDRESS")
.map(|v| !v.is_empty())
.unwrap_or(false);
#[cfg(not(target_os = "linux"))]
let dbus_ok = true;
if !dbus_ok {
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.emit(event_name, ());
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(())
@@ -239,6 +319,8 @@ pub fn run() {
exit_app,
register_global_shortcut,
unregister_global_shortcut,
mpris_set_metadata,
mpris_set_playback,
audio::audio_play,
audio::audio_pause,
audio::audio_resume,
+5 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.16.0",
"version": "1.17.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -55,6 +55,10 @@
"bundleMediaFramework": true
}
},
"macOS": {
"entitlements": "Entitlements.plist",
"minimumSystemVersion": "10.15"
},
"windows": {
"wix": {
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
+1 -1
View File
@@ -146,7 +146,7 @@ export default function NowPlayingDropdown() {
<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: '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} />
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
{stream.minutesAgo > 0 && <span> {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>}
+20 -17
View File
@@ -13,25 +13,25 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Games',
themes: [
{ id: 'ascalon', label: 'Ascalon', bg: '#1c1a17', card: '#0f0d0b', accent: '#d4af37' },
{ id: 'azerothian-gold', label: 'Azerothian Gold', bg: '#1a1a1a', card: '#0a0a0a', accent: '#c19e67' },
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
{ id: 'tetrastack', label: 'TetraStack', bg: '#0a0a0a', card: '#151515', accent: '#00f0f0' },
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
],
},
{
group: 'Movies',
themes: [
{ id: 'b-runner', label: 'B-Runner', bg: '#141211', card: '#080707', accent: '#ff9500' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'dune', label: 'Dune', bg: '#1c1917', card: '#0c0a09', accent: '#ff8c00' },
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#1a1d23', card: '#0a0a0c', accent: '#ff9900' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0F172A', card: '#050C1A', accent: '#E62429' },
{ id: 'dune', label: 'Dune', bg: '#1c1408', card: '#0e0c1a', accent: '#c8780a' },
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#0d0b18', card: '#141120', accent: '#ff8c00' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f0e0b0', card: '#241a0e', accent: '#d4a820' },
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
{ 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: 't-800', label: 'T-800', bg: '#1f242d', card: '#0a0c10', accent: '#00d4ff' },
],
@@ -61,10 +61,13 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'w3-1', label: 'W3.1', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
{ id: 'aero-glass', label: 'W7', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
{ 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' },
],
},
{
@@ -80,11 +83,11 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Mediaplayer',
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: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'muma-jukebox', label: 'MuMa Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
@@ -93,9 +96,9 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Series',
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: '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' },
],
},
+28 -12
View File
@@ -148,6 +148,7 @@ const enTranslation = {
albums: 'Albums',
songs: 'Songs',
enqueueAll: 'Add all to queue',
removeSong: 'Remove from favorites',
},
randomAlbums: {
title: 'Random Albums',
@@ -168,17 +169,20 @@ const enTranslation = {
play: 'Play',
trackGenre: 'Genre',
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',
genreAddedToBlacklist: 'Added to filter list',
genreAlreadyBlocked: 'Already blocked',
artistBlocked: 'Artist blocked',
artistAddedToBlacklist: 'Artist added to filter list',
artistClickHint: 'Click to block this artist',
blacklistToggle: 'Keyword Filter',
genreMixTitle: 'Genre Mix',
genreMixDesc: 'Select a genre to get a curated random mix',
genreMixLoadMore: 'Load 10 more',
genreMixNoGenres: 'No genres found on server.',
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: {
title: 'Playlists',
@@ -341,7 +345,7 @@ const enTranslation = {
changelog: 'Changelog',
randomMixTitle: 'Random Mix',
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…',
randomMixBlacklistAdd: 'Add',
randomMixBlacklistEmpty: 'No custom keywords added yet.',
@@ -691,6 +695,7 @@ const deTranslation = {
albums: 'Alben',
songs: 'Songs',
enqueueAll: 'Alle in die Warteschlange',
removeSong: 'Aus Favoriten entfernen',
},
randomAlbums: {
title: 'Zufallsalben',
@@ -711,17 +716,20 @@ const deTranslation = {
play: 'Abspielen',
trackGenre: 'Genre',
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',
genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
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',
genreMixTitle: 'Genre-Mix',
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
genreMixLoadMore: '10 weitere laden',
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
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: {
title: 'Playlists',
@@ -884,7 +892,7 @@ const deTranslation = {
changelog: 'Changelog',
randomMixTitle: 'Zufallsmix',
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…',
randomMixBlacklistAdd: 'Hinzufügen',
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
@@ -1234,6 +1242,7 @@ const frTranslation = {
albums: 'Albums',
songs: 'Morceaux',
enqueueAll: 'Tout ajouter à la file',
removeSong: 'Retirer des favoris',
},
randomAlbums: {
title: 'Albums aléatoires',
@@ -1254,17 +1263,20 @@ const frTranslation = {
play: 'Lire',
trackGenre: 'Genre',
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é',
genreAddedToBlacklist: 'Ajouté à la liste de filtres',
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é',
genreMixTitle: 'Mix par genre',
genreMixDesc: 'Sélectionnez un genre pour obtenir un mix aléatoire',
genreMixLoadMore: 'Charger 10 de plus',
genreMixNoGenres: 'Aucun genre trouvé sur le serveur.',
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: {
title: 'Listes de lecture',
@@ -1427,7 +1439,7 @@ const frTranslation = {
changelog: 'Journal des modifications',
randomMixTitle: 'Mix aléatoire',
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é…',
randomMixBlacklistAdd: 'Ajouter',
randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.',
@@ -1777,6 +1789,7 @@ const nlTranslation = {
albums: 'Albums',
songs: 'Nummers',
enqueueAll: 'Alles aan wachtrij toevoegen',
removeSong: 'Verwijderen uit favorieten',
},
randomAlbums: {
title: 'Willekeurige albums',
@@ -1797,17 +1810,20 @@ const nlTranslation = {
play: 'Afspelen',
trackGenre: 'Genre',
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',
genreAddedToBlacklist: 'Aan filterlijst toegevoegd',
genreAlreadyBlocked: 'Al geblokkeerd',
artistBlocked: 'Artiest geblokkeerd',
artistAddedToBlacklist: 'Artiest aan filterlijst toegevoegd',
artistClickHint: 'Klik om deze artiest te blokkeren',
blacklistToggle: 'Trefwoordfilter',
genreMixTitle: 'Genremix',
genreMixDesc: 'Selecteer een genre voor een samengestelde willekeurige mix',
genreMixLoadMore: '10 meer laden',
genreMixNoGenres: 'Geen genres gevonden op server.',
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: {
title: 'Afspeellijsten',
@@ -1970,7 +1986,7 @@ const nlTranslation = {
changelog: 'Wijzigingslog',
randomMixTitle: 'Willekeurige mix',
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…',
randomMixBlacklistAdd: 'Toevoegen',
randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.',
+20 -2
View File
@@ -3,9 +3,10 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, ListPlus } from 'lucide-react';
import { ListPlus, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
export default function Favorites() {
const { t } = useTranslation();
@@ -15,6 +16,11 @@ export default function Favorites() {
const [loading, setLoading] = useState(true);
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 navigate = useNavigate();
@@ -77,11 +83,12 @@ export default function Favorites() {
</button>
</div>
<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>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div />
</div>
{songs.map((song, i) => {
const track = {
@@ -93,6 +100,7 @@ export default function Favorites() {
<div
key={song.id}
className="track-row track-row-va"
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
onDoubleClick={() => playTrack(song, songs)}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
@@ -118,6 +126,16 @@ export default function Favorites() {
<div className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</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>
);
})}
+41 -1
View File
@@ -48,6 +48,7 @@ export default function RandomMix() {
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null);
// Blacklist panel state
const [blacklistOpen, setBlacklistOpen] = useState(false);
@@ -94,6 +95,7 @@ export default function RandomMix() {
if (song.genre && checkText(song.genre)) return false;
if (song.title && checkText(song.title)) return false;
if (song.album && checkText(song.album)) return false;
if (song.artist && checkText(song.artist)) return false;
return true;
});
@@ -419,7 +421,45 @@ export default function RandomMix() {
</div>
<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 className="track-info">
+36 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
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 { useAuthStore } from './authStore';
@@ -335,8 +335,43 @@ export function initAudioListeners(): () => void {
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 () => {
unsubAuth();
unsubMpris();
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'azerothian-gold' | 'ascalon' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'b-runner' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'jayfin';
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 {
theme: Theme;
+23
View File
@@ -2556,6 +2556,29 @@
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 ─ */
.stats-page {
display: flex;
+1
View File
@@ -332,6 +332,7 @@
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-6);
contain: paint;
}
/* ─── Player Bar ─── */
+3937 -1072
View File
File diff suppressed because it is too large Load Diff