Compare commits

...

4 Commits

Author SHA1 Message Date
Psychotoxical 2ba7845c79 feat: Last.fm beta, Similar Artists, Statistics Last.fm stats, TooltipPortal, CustomSelect, Psychowave theme (v1.7.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 18:35:26 +01:00
Psychotoxical 9400a5fb2b docs: warn about settings reset in v1.6.0 changelog (identifier change)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:48:26 +01:00
Psychotoxical 0e88e8a5cd feat: Replay Gain, Crossfade, Download Folder Modal, Changelog in Settings (v1.6.0)
### Audio
- Replay Gain support (track + album mode, configurable pre-gain, hard limiter)
- Crossfade between tracks (configurable duration 1–10 s)
- Gapless preloading ⚠️ experimental/alpha — enable in Settings → Playback
- Atomic sink swap: old track plays until new one is decoded, then stops cleanly

### UI / UX
- Settings redesigned with tab navigation (General, Playback, About)
- Changelog viewer in Settings → About with collapsible version entries
- Download Folder Modal: choose folder + "remember" checkbox per-download
- EQ popup now accessible directly from the Player Bar
- "Also Featured On" section on Artist pages for non-album appearances

### Fixes
- Bundle identifier changed from dev.psysonic.app → dev.psysonic.player (fixes macOS warning)
- Version sync: all four version files (package.json, Cargo.toml, tauri.conf.json, PKGBUILD) now at 1.6.0

### Known Issues
- FLAC seeking via waveform seekbar does not work (MP3/OGG unaffected) — under investigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:23:17 +01:00
Psychotoxical 7de4b97df0 chore: bump AUR PKGBUILD to v1.5.0, fix tag URL format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:26:28 +01:00
39 changed files with 3936 additions and 990 deletions
+5
View File
@@ -100,6 +100,8 @@ jobs:
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
@@ -131,6 +133,9 @@ jobs:
run: npm install
- name: build
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
run: npm run tauri:build -- --bundles deb,rpm
- name: upload Linux artifacts
+5
View File
@@ -7,6 +7,11 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment variables (API keys)
.env
.env.local
.env.*.local
# Node
node_modules
dist
+107
View File
@@ -5,6 +5,113 @@ 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.7.0] - 2026-03-20
### Added
#### Last.fm Integration *(Beta)*
- **Direct Last.fm scrobbling**: Tracks are scrobbled directly via the Last.fm API at 50% playback — no longer routed through Navidrome. Configure in Settings → Server with your Last.fm username and password.
- **Now Playing updates**: Last.fm receives the currently playing track in real time.
- **Love / Unlove**: Heart button in the Now Playing page and player bar syncs the loved state with Last.fm instantly.
- **Last.fm profile badge** in Settings → Server: shows your scrobble count and member since year once connected.
- ⚠️ **This feature is in beta.** Session management and edge cases are still being refined.
#### Similar Artists
- Artist detail pages now show a **Similar Artists** section below Top Tracks, sourced from Last.fm and filtered to artists actually present in your library. Shown as chip buttons — click to navigate directly to that artist's page.
- Requires Last.fm to be configured. Hidden when Last.fm is not connected or no library matches are found.
#### Statistics — Last.fm Stats
- New **Last.fm Stats** section on the Statistics page (requires Last.fm): top artists, albums, and tracks with proportional play-count bars.
- **Period filter**: switch between Last 7 Days, 1 Month, 3 Months, 6 Months, 12 Months, and Overall.
- **Recent Scrobbles**: last 20 scrobbled tracks with relative timestamps and a "Now Playing" badge for the currently active entry.
- **Genre Distribution removed**: replaced by the Last.fm stats sections.
#### Psychowave Theme *(Work in Progress)*
- New **Psychowave** theme: a deep purple/violet dark theme inspired by synthwave and retrowave aesthetics.
- ⚠️ **Still in active development** — colors and details will continue to be refined in upcoming releases.
#### Tooltip System — TooltipPortal
- All tooltips now use a **React portal** rendered into `document.body` at `z-index: 99999`. Replaces the previous CSS `::after` pseudo-element system.
- Fixes tooltip clipping inside `overflow: hidden` containers (player bar, queue panel, EQ).
- Fixes black OS-native tooltip boxes that appeared on native `title=` attributes — all converted to `data-tooltip`.
- Smart edge detection: tooltip flips position automatically when it would overflow the viewport.
#### Custom Select Dropdowns
- **Theme**, **Language**, and **EQ preset** selectors are now rendered as styled portal dropdowns — no more unstyled native `<select>` boxes.
- Supports option groups (EQ: Built-in Presets / Custom Presets), keyboard navigation, and click-outside-to-close.
### Changed
#### Fullscreen Player / Now Playing — Background
- **Ken Burns animation improved**: background image now has significantly more movement (±8% translate, `inset: -30%`) with a 90-second cycle — more cinematic without being distracting.
- **Color orbs removed** from both the Fullscreen Player and the Now Playing page. They caused noticeable GPU load especially on integrated graphics.
### Fixed
- **Live dropdown (Now Playing)**: Own playback was no longer reported to Navidrome after the Last.fm implementation removed the `reportNowPlaying` call. Both are now called independently on track start.
- **Sidebar: Now Playing button position when collapsed**: The button was appearing in the middle of the nav instead of just above the System section. Caused by a leftover `margin-top: auto` on the Statistics link that split the remaining flex space.
---
## [1.6.0] - 2026-03-19
> ⚠️ **Wichtiger Hinweis / Important Notice**
>
> **DE:** Der Bundle-Identifier der App wurde von `dev.psysonic.app` auf `dev.psysonic.player` geändert. **Alle gespeicherten Einstellungen (Server-Profile, Theme, EQ, Sprache usw.) gehen beim Update auf diese Version einmalig verloren** und müssen neu eingetragen werden. Zukünftige Updates sind davon nicht betroffen.
>
> **EN:** The app's bundle identifier has changed from `dev.psysonic.app` to `dev.psysonic.player`. **All saved settings (server profiles, theme, EQ, language, etc.) will be reset once when updating to this version** and need to be re-entered. Future updates are not affected.
### Added
#### Replay Gain
- **Replay Gain support** in the Rust audio engine. Gain and peak values from the Subsonic API are applied per-track at playback time, keeping loudness consistent across your library.
- Two modes selectable in Settings → Playback: **Track** (default) and **Album** gain.
- Peak limiting applied to prevent clipping: effective gain is capped at `1 / peak`.
- Volume slider preserves the gain ratio — `audio_set_volume` multiplies `base_volume × replay_gain_linear`.
#### Crossfade
- **Crossfade between tracks** (0.5 12 s, configurable in Settings → Playback).
- Old sink is volume-ramped to zero in 30 steps while the new track starts playing; old sink stored in `fading_out_sink` so a subsequent skip cancels the fade-out immediately.
- `audio_set_crossfade` Tauri command; synced to Rust on startup and on toggle.
#### Gapless Preloading *(Experimental — Alpha)*
- **Gapless playback**: when ≤ 30 s remain in the current track, the next track's audio is preloaded via `audio_preload` in the background.
- `audio_play` checks the preload cache first — if there is a URL match the download is skipped entirely, eliminating the gap between tracks.
- The old Sink is kept alive during the new track's download and decode phase; the Sink swap happens atomically after decoding is complete, fixing a subtle **start-of-track audio cut** that occurred regardless of gapless state.
- ⚠️ **This feature is experimental and still in active development.** It may not work correctly in all scenarios. Enable it in Settings → Playback at your own discretion.
#### Settings — Tab Navigation
- Settings reorganised into **5 horizontal tabs**: Playback, Library, Appearance, Server, About.
- Each tab groups related settings with a matching icon.
#### Artist Pages — "Also Featured On"
- Artist detail pages now show an **"Also Featured On"** section listing albums where the artist appears as a guest or featured performer (but is not the primary album artist).
- Implemented via `search3` filtered by `song.artistId`, excluding the artist's own albums.
#### Download Folder Modal
- When no download folder is configured and the user initiates a download (album or track), a **folder picker modal** now appears asking where to save.
- Includes a "Remember this folder" checkbox that writes the choice to Settings.
- Clear button added in Settings → Server to reset the saved download folder.
#### Changelog in Settings
- The full **Changelog** is now readable inside the app under Settings → About.
- Rendered as collapsible version entries; the current version is expanded by default.
- Inline Markdown (`**bold**`, `*italic*`, `` `code` ``) is rendered natively.
#### EQ as Player Bar Popup
- The Equalizer is now accessible directly from the **player bar** via a small EQ button, opening as a centred popup overlay — no need to navigate to Settings.
### Fixed
- **Bundle identifier warning**: changed `identifier` from `dev.psysonic.app` to `dev.psysonic.player` to avoid the macOS `.app` extension conflict warned by Tauri.
- **Version mismatch in releases**: `tauri.conf.json` version was out of sync with `package.json` and `Cargo.toml`, causing GitHub Actions to build release artefacts with the wrong version number. All four version sources (`package.json`, `Cargo.toml`, `tauri.conf.json`, `packages/aur/PKGBUILD`) are now kept in sync.
### Known Issues
- **FLAC seeking**: jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected.
---
## [1.5.0] - 2026-03-18
### Added
+1 -1
View File
@@ -182,4 +182,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **RandomAlbums**: No auto-refresh timer — loads once on mount, manual refresh button only. `loadingRef` guards against concurrent fetches.
- **Queue shuffle**: `shuffleQueue()` in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest.
- **Tooltip z-index**: `.main-content` has `z-index: 1` so tooltips in the content area render above the queue panel (which has no z-index but appears later in DOM order). Multi-line tooltips: add `data-tooltip-wrap` attribute + use `\n` in the string; CSS rule `[data-tooltip-wrap]::after { white-space: pre-line; max-width: 220px }`.
- **Version**: 1.5.0
- **Version**: 1.7.0
+1
View File
@@ -40,6 +40,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ● Known Limitations
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
- **FLAC seeking**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated.
## 📥 Installation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.5.0",
"version": "1.7.0",
"private": true,
"scripts": {
"dev": "vite",
+4 -4
View File
@@ -1,6 +1,6 @@
# Maintainer: stelle <stelle@psychotoxical.dev>
pkgname=psysonic
pkgver=1.4.5
pkgver=1.7.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -18,11 +18,11 @@ makedepends=(
'rust'
'cargo'
)
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "psysonic-app-v$pkgver"
cd "psysonic-$pkgver"
export CARGO_HOME="$srcdir/cargo-home"
export npm_config_cache="$srcdir/npm-cache"
@@ -32,7 +32,7 @@ build() {
}
package() {
cd "psysonic-app-v$pkgver"
cd "psysonic-$pkgver"
# Binary (in /usr/lib to make room for the wrapper)
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
+8 -1
View File
@@ -2273,6 +2273,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -3133,9 +3139,10 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.5.0"
version = "1.7.0"
dependencies = [
"biquad",
"md5",
"reqwest 0.12.28",
"rodio",
"serde",
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.5.0"
version = "1.7.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -31,7 +31,8 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
reqwest = { version = "0.12", features = ["stream"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
tauri-plugin-window-state = "2.4.1"
+157 -36
View File
@@ -137,6 +137,12 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
// ─── Engine state (registered as Tauri managed state) ────────────────────────
pub(crate) struct PreloadedTrack {
url: String,
data: Vec<u8>,
duration_hint: f64,
}
pub struct AudioEngine {
pub stream_handle: Arc<rodio::OutputStreamHandle>,
pub current: Arc<Mutex<AudioCurrent>>,
@@ -147,6 +153,10 @@ pub struct AudioEngine {
pub http_client: reqwest::Client,
pub eq_gains: Arc<[AtomicU32; 10]>,
pub eq_enabled: Arc<AtomicBool>,
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>, // f32 stored as bits
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
}
pub struct AudioCurrent {
@@ -159,6 +169,8 @@ pub struct AudioCurrent {
pub play_started: Option<Instant>,
/// Set when paused; holds the position at pause time.
pub paused_at: Option<f64>,
pub replay_gain_linear: f32,
pub base_volume: f32,
}
impl AudioCurrent {
@@ -207,6 +219,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
seek_offset: 0.0,
play_started: None,
paused_at: None,
replay_gain_linear: 1.0,
base_volume: 0.8,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: reqwest::Client::builder()
@@ -215,6 +229,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
.unwrap_or_default(),
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
eq_enabled: Arc::new(AtomicBool::new(false)),
preloaded: Arc::new(Mutex::new(None)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
fading_out_sink: Arc::new(Mutex::new(None)),
};
(engine, thread)
@@ -238,43 +256,61 @@ pub async fn audio_play(
url: String,
volume: f32,
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Claim this generation — any in-flight progress task with the old gen will exit.
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// Stop existing playback immediately.
// Stop any previous fading sink unconditionally.
{
let mut cur = state.current.lock().unwrap();
if let Some(sink) = cur.sink.take() {
sink.stop();
let mut fo = state.fading_out_sink.lock().unwrap();
if let Some(old) = fo.take() {
old.stop();
}
cur.seek_offset = 0.0;
cur.play_started = None;
cur.paused_at = None;
cur.duration_secs = duration_hint;
}
// ── Download ──────────────────────────────────────────────────────────────
let response = state
.http_client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
// NOTE: We intentionally do NOT stop the current Sink here.
// The old Sink keeps playing while we download + decode the new track.
// We swap Sinks atomically only after the new data is ready, so the
// old track plays to completion (or as close to it as possible).
if !response.status().is_success() {
let status = response.status().as_u16();
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
// ── Check preload cache or download ──────────────────────────────────────
let data: Vec<u8> = {
let cached = {
let mut preloaded = state.preloaded.lock().unwrap();
if let Some(p) = preloaded.as_ref().filter(|p| p.url == url) {
let d = p.data.clone();
let _ = p.duration_hint; // used for type check
*preloaded = None;
Some(d)
} else {
None
}
};
if let Some(cached_data) = cached {
cached_data
} else {
let response = state
.http_client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
let status = response.status().as_u16();
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let msg = format!("HTTP {status}");
app.emit("audio:error", &msg).ok();
return Err(msg);
}
response.bytes().await.map_err(|e| e.to_string())?.into()
}
let msg = format!("HTTP {status}");
app.emit("audio:error", &msg).ok();
return Err(msg);
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
};
// Bail if superseded while downloading.
if state.generation.load(Ordering::SeqCst) != gen {
@@ -282,7 +318,6 @@ pub async fn audio_play(
}
// ── Decode ────────────────────────────────────────────────────────────────
let data: Vec<u8> = bytes.into();
// Trust the Subsonic API duration_hint as the primary source.
// Decoder::total_duration() is unreliable for VBR MP3 (symphonia may
@@ -311,9 +346,21 @@ pub async fn audio_play(
return Ok(());
}
// ── Create sink and start playback ────────────────────────────────────────
// ── Compute replay gain ───────────────────────────────────────────────────
let gain_linear: f32 = replay_gain_db
.map(|db| 10f32.powf(db / 20.0))
.unwrap_or(1.0);
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
// Limit gain so peak sample doesn't exceed 1.0 (prevent clipping).
let gain_linear = gain_linear.min(1.0 / peak);
let effective_volume = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
// ── Create new sink ───────────────────────────────────────────────────────
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
sink.set_volume(volume.clamp(0.0, 1.0));
sink.set_volume(effective_volume);
let eq_source = EqSource::new(
decoder.convert_samples::<f32>(),
state.eq_gains.clone(),
@@ -321,13 +368,49 @@ pub async fn audio_play(
);
sink.append(eq_source);
{
// Atomically swap sinks: take old one, install new one.
// Capture old volume so the crossfade fade-out starts at the right level.
let (old_sink, old_vol) = {
let mut cur = state.current.lock().unwrap();
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0);
let old = cur.sink.take();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
(old, old_vol)
};
// Handle old sink: crossfade fade-out or immediate stop.
// The new track is already playing; the old sink runs in parallel during a crossfade.
if crossfade_enabled {
if let Some(old) = old_sink {
// Park the old sink in fading_out_sink so a subsequent audio_play can cancel it.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
tokio::spawn(async move {
let steps: u32 = 30;
let step_ms = ((crossfade_secs_val * 1000.0) / steps as f32) as u64;
for i in (0..=steps).rev() {
{
let fo = fo_arc.lock().unwrap();
match fo.as_ref() {
Some(s) => s.set_volume(old_vol * (i as f32 / steps as f32)),
None => return, // cancelled by a subsequent audio_play
}
}
tokio::time::sleep(Duration::from_millis(step_ms)).await;
}
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
} else if let Some(old) = old_sink {
old.stop();
}
app.emit("audio:playing", duration_secs).ok();
@@ -340,13 +423,14 @@ pub async fn audio_play(
// Instead we use the wall-clock position (seek_offset + elapsed).
// `AudioCurrent::position()` is clamped to `duration_secs`, so once it
// reaches the end it stays there. We fire `audio:ended` after two
// consecutive ticks where position >= duration - 1.0 s, which:
// • avoids false positives from seeking very close to the end
// • fires roughly 0.51 s before the last sample, giving the frontend
// enough time to queue the next download.
// consecutive ticks where position >= duration - threshold, where threshold
// is extended to crossfade_secs when crossfade is enabled so the frontend
// gets time to start the next track during the fade.
let gen_counter = state.generation.clone();
let current_arc = state.current.clone();
let app_clone = app.clone();
let crossfade_enabled_arc = state.crossfade_enabled.clone();
let crossfade_secs_arc = state.crossfade_secs.clone();
tokio::spawn(async move {
let mut near_end_ticks: u32 = 0;
@@ -375,8 +459,11 @@ pub async fn audio_play(
continue;
}
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
if dur > 1.0 && pos >= dur - 1.0 {
if dur > end_threshold && pos >= dur - end_threshold {
near_end_ticks += 1;
if near_end_ticks >= 2 {
gen_counter.fetch_add(1, Ordering::SeqCst);
@@ -450,9 +537,10 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
#[tauri::command]
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let cur = state.current.lock().unwrap();
let mut cur = state.current.lock().unwrap();
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
sink.set_volume(volume.clamp(0.0, 1.0));
sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0));
}
}
@@ -463,3 +551,36 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngin
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
}
}
#[tauri::command]
pub async fn audio_preload(
url: String,
duration_hint: f64,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Don't re-download if already preloaded for this URL.
{
let preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
return Ok(());
}
}
let response = state
.http_client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(()); // silently fail preload
}
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data, duration_hint });
Ok(())
}
#[tauri::command]
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed);
}
+59
View File
@@ -19,6 +19,62 @@ fn exit_app(app_handle: tauri::AppHandle) {
app_handle.exit(0);
}
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
/// `params` is a list of [key, value] pairs (method must be included).
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
#[tauri::command]
async fn lastfm_request(
params: Vec<[String; 2]>,
sign: bool,
get: bool,
api_key: String,
api_secret: String,
) -> Result<serde_json::Value, String> {
use std::collections::HashMap;
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
map.insert("api_key".into(), api_key.clone());
if sign {
let mut keys: Vec<String> = map.keys().cloned().collect();
keys.sort();
let sig_str: String = keys.iter()
.filter(|k| k.as_str() != "format" && k.as_str() != "callback")
.map(|k| format!("{}{}", k, map[k]))
.collect::<String>();
let sig_input = format!("{}{}", sig_str, api_secret);
let digest = md5::compute(sig_input.as_bytes());
map.insert("api_sig".into(), format!("{:x}", digest));
}
map.insert("format".into(), "json".into());
let client = reqwest::Client::new();
let resp = if get {
client
.get("https://ws.audioscrobbler.com/2.0/")
.query(&map)
.header("User-Agent", "psysonic/1.6.0")
.send()
.await
} else {
client
.post("https://ws.audioscrobbler.com/2.0/")
.form(&map)
.header("User-Agent", "psysonic/1.6.0")
.send()
.await
}.map_err(|e| e.to_string())?;
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if let Some(err) = json.get("error") {
return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
}
Ok(json)
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
@@ -126,6 +182,9 @@ pub fn run() {
audio::audio_seek,
audio::audio_set_volume,
audio::audio_set_eq,
audio::audio_preload,
audio::audio_set_crossfade,
lastfm_request,
])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
+2 -2
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.4.5",
"identifier": "dev.psysonic.app",
"version": "1.7.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
+6 -1
View File
@@ -29,6 +29,8 @@ import SearchResults from './pages/SearchResults';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import DownloadFolderModal from './components/DownloadFolderModal';
import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import { useConnectionStatus } from './hooks/useConnectionStatus';
@@ -142,7 +144,8 @@ function AppShell() {
<button
className="collapse-btn"
onClick={toggleQueue}
title={t('player.toggleQueue')}
data-tooltip={t('player.toggleQueue')}
data-tooltip-pos="bottom"
>
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
</button>
@@ -189,6 +192,8 @@ function AppShell() {
<FullscreenPlayer onClose={toggleFullscreen} />
)}
<ContextMenu />
<DownloadFolderModal />
<TooltipPortal />
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
import { invoke } from '@tauri-apps/api/core';
const API_KEY = import.meta.env.VITE_LASTFM_API_KEY as string;
const API_SECRET = import.meta.env.VITE_LASTFM_API_SECRET as string;
export function lastfmIsConfigured(): boolean {
return Boolean(API_KEY && API_SECRET);
}
function errMsg(e: unknown): string {
if (typeof e === 'string') return e;
if (e instanceof Error) return e.message;
return String(e);
}
async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
const entries = Object.entries(params) as [string, string][];
return invoke('lastfm_request', {
params: entries,
sign,
get,
apiKey: API_KEY,
apiSecret: API_SECRET,
});
}
export async function lastfmGetToken(): Promise<string> {
try {
const data = await call({ method: 'auth.getToken' }, false, true);
return data.token as string;
} catch (e) {
throw new Error(errMsg(e));
}
}
export function lastfmAuthUrl(token: string): string {
return `https://www.last.fm/api/auth/?api_key=${API_KEY}&token=${token}`;
}
export async function lastfmGetSession(token: string): Promise<{ key: string; name: string }> {
try {
const data = await call({ method: 'auth.getSession', token }, true, false);
return { key: data.session.key as string, name: data.session.name as string };
} catch (e) {
throw new Error(errMsg(e));
}
}
export async function lastfmGetSimilarArtists(artistName: string): Promise<string[]> {
try {
const data = await call({ method: 'artist.getSimilar', artist: artistName, limit: '50' }, false, true);
const artists = data?.similarartists?.artist;
if (!artists) return [];
const arr = Array.isArray(artists) ? artists : [artists];
return arr.map((a: any) => a.name as string);
} catch {
return [];
}
}
export async function lastfmGetAllLovedTracks(
username: string,
sessionKey: string,
): Promise<Array<{ title: string; artist: string }>> {
const results: Array<{ title: string; artist: string }> = [];
let page = 1;
const limit = 200;
while (true) {
try {
const data = await call({
method: 'user.getLovedTracks',
user: username,
sk: sessionKey,
limit: String(limit),
page: String(page),
}, false, true);
const tracks = data?.lovedtracks?.track;
if (!tracks) break;
const arr = Array.isArray(tracks) ? tracks : [tracks];
for (const t of arr) {
results.push({ title: t.name, artist: t.artist?.name ?? '' });
}
const totalPages = Number(data?.lovedtracks?.['@attr']?.totalPages ?? 1);
if (page >= totalPages || page >= 10) break; // max 10 pages = 2000 tracks
page++;
} catch {
break;
}
}
return results;
}
export async function lastfmGetTrackLoved(
title: string,
artist: string,
sessionKey: string,
): Promise<boolean> {
try {
const data = await call({ method: 'track.getInfo', track: title, artist, sk: sessionKey }, false, true);
return data?.track?.userloved === '1' || data?.track?.userloved === 1;
} catch {
return false;
}
}
export async function lastfmUpdateNowPlaying(
track: { title: string; artist: string; album: string; duration: number },
sessionKey: string,
): Promise<void> {
try {
await call({
method: 'track.updateNowPlaying',
track: track.title,
artist: track.artist,
album: track.album,
duration: String(Math.round(track.duration)),
sk: sessionKey,
}, true, false);
} catch {
// best effort
}
}
export async function lastfmLoveTrack(
track: { title: string; artist: string },
sessionKey: string,
): Promise<void> {
try {
await call({ method: 'track.love', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
} catch {
// best effort
}
}
export async function lastfmUnloveTrack(
track: { title: string; artist: string },
sessionKey: string,
): Promise<void> {
try {
await call({ method: 'track.unlove', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
} catch {
// best effort
}
}
export interface LastfmUserInfo {
playcount: number;
registeredAt: number; // unix timestamp
}
export async function lastfmGetUserInfo(
username: string,
sessionKey: string,
): Promise<LastfmUserInfo | null> {
try {
const data = await call({ method: 'user.getInfo', user: username, sk: sessionKey }, false, true);
const u = data?.user;
if (!u) return null;
return {
playcount: Number(u.playcount),
registeredAt: Number(u.registered?.unixtime ?? 0),
};
} catch {
return null;
}
}
export interface LastfmRecentTrack {
name: string;
artist: string;
album: string;
timestamp: number | null; // null = currently playing
nowPlaying: boolean;
}
export async function lastfmGetRecentTracks(
username: string,
sessionKey: string,
limit = 20,
): Promise<LastfmRecentTrack[]> {
try {
const data = await call({ method: 'user.getRecentTracks', user: username, sk: sessionKey, limit: String(limit) }, false, true);
const items = data?.recenttracks?.track;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((t: any) => ({
name: t.name,
artist: t.artist?.['#text'] ?? t.artist?.name ?? '',
album: t.album?.['#text'] ?? '',
timestamp: t.date?.uts ? Number(t.date.uts) : null,
nowPlaying: t['@attr']?.nowplaying === 'true',
}));
} catch {
return [];
}
}
export type LastfmPeriod = 'overall' | '7day' | '1month' | '3month' | '6month' | '12month';
export interface LastfmTopArtist {
name: string;
playcount: string;
}
export interface LastfmTopAlbum {
name: string;
playcount: string;
artist: string;
}
export interface LastfmTopTrack {
name: string;
playcount: string;
artist: string;
}
export async function lastfmGetTopArtists(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopArtist[]> {
try {
const data = await call({ method: 'user.getTopArtists', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.topartists?.artist;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount }));
} catch {
return [];
}
}
export async function lastfmGetTopAlbums(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopAlbum[]> {
try {
const data = await call({ method: 'user.getTopAlbums', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.topalbums?.album;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount, artist: a.artist?.name ?? '' }));
} catch {
return [];
}
}
export async function lastfmGetTopTracks(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopTrack[]> {
try {
const data = await call({ method: 'user.getTopTracks', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.toptracks?.track;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((t: any) => ({ name: t.name, playcount: t.playcount, artist: t.artist?.name ?? '' }));
} catch {
return [];
}
}
export async function lastfmScrobble(
track: { title: string; artist: string; album: string; duration: number },
timestamp: number,
sessionKey: string,
): Promise<void> {
try {
await call({
method: 'track.scrobble',
track: track.title,
artist: track.artist,
album: track.album,
duration: String(Math.round(track.duration)),
timestamp: String(Math.floor(timestamp / 1000)),
sk: sessionKey,
}, true, false);
} catch {
// best effort
}
}
+7
View File
@@ -76,6 +76,13 @@ export interface SubsonicSong {
starred?: string;
genre?: string;
path?: string;
albumArtist?: string;
replayGain?: {
trackGain?: number;
albumGain?: number;
trackPeak?: number;
albumPeak?: number;
};
}
export interface SubsonicPlaylist {
+1 -1
View File
@@ -19,7 +19,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
: t('connection.checking');
return (
<div className="connection-indicator" title={title}>
<div className="connection-indicator" data-tooltip={title} data-tooltip-pos="bottom">
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<span className="connection-type">{label}</span>
+41 -17
View File
@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
@@ -19,8 +21,9 @@ function sanitizeFilename(name: string): string {
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong } = usePlayerStore();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
@@ -74,25 +77,16 @@ export default function ContextMenu() {
const downloadAlbum = async (albumName: string, albumId: string) => {
try {
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const url = buildDownloadUrl(albumId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} else {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${sanitizeFilename(albumName)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(albumName)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} catch (e) {
console.error('Download failed:', e);
}
@@ -157,6 +151,21 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
</div>
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = lastfmLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setLastfmLovedForSong(song.title, song.artist, newLoved);
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
})}>
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
</div>
);
})()}
</>
);
})()}
@@ -218,6 +227,21 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
</div>
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = lastfmLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setLastfmLovedForSong(song.title, song.artist, newLoved);
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
})}>
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
</div>
);
})()}
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown } from 'lucide-react';
export interface SelectOption {
value: string;
label: string;
group?: string; // group label — shown as non-selectable header when it changes
disabled?: boolean;
}
interface Props {
value: string;
options: SelectOption[];
onChange: (value: string) => void;
className?: string;
style?: React.CSSProperties;
}
export default function CustomSelect({ value, options, onChange, className = '', style }: Props) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [dropStyle, setDropStyle] = useState<React.CSSProperties>({});
const selected = options.find(o => o.value === value);
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const maxH = 240;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
setDropStyle({
position: 'fixed',
left: rect.left,
width: rect.width,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!listRef.current?.contains(e.target as Node)
) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<>
<button
ref={triggerRef}
type="button"
className={`custom-select-trigger ${className}`}
style={style}
onClick={() => setOpen(v => !v)}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="custom-select-label">{selected?.label ?? value}</span>
<ChevronDown size={14} className={`custom-select-chevron ${open ? 'open' : ''}`} />
</button>
{open && createPortal(
<div
ref={listRef}
className="custom-select-dropdown"
style={dropStyle}
role="listbox"
>
{options.reduce<React.ReactNode[]>((acc, opt, i) => {
const prevGroup = i > 0 ? options[i - 1].group : undefined;
if (opt.group && opt.group !== prevGroup) {
acc.push(
<div key={`group-${opt.group}`} className="custom-select-group-label">
{opt.group}
</div>
);
}
acc.push(
<div
key={opt.value}
className={`custom-select-option ${opt.value === value ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''}`}
role="option"
aria-selected={opt.value === value}
onMouseDown={() => { if (!opt.disabled) { onChange(opt.value); setOpen(false); } }}
>
{opt.label}
</div>
);
return acc;
}, [])}
</div>,
document.body
)}
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { FolderOpen } from 'lucide-react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { useAuthStore } from '../store/authStore';
export default function DownloadFolderModal() {
const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore();
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
const { t } = useTranslation();
const handleBrowse = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('common.chooseDownloadFolder') });
if (selected && typeof selected === 'string') setFolder(selected);
};
if (!isOpen) return null;
return (
<>
<div className="eq-popup-backdrop" onClick={cancel} style={{ zIndex: 210 }} />
<div className="eq-popup" style={{ zIndex: 211, width: 'min(480px, 92vw)', gap: 0 }}>
<div className="eq-popup-header">
<span className="eq-popup-title">{t('common.chooseDownloadFolder')}</span>
</div>
<div style={{ padding: '16px 0 12px' }}>
<div className="download-folder-pick-row">
<span className="download-folder-path">
{folder || t('common.noFolderSelected')}
</span>
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
<FolderOpen size={15} /> {t('settings.pickFolder')}
</button>
</div>
<label className="download-remember-row">
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
<span>{t('common.rememberDownloadFolder')}</span>
</label>
</div>
<div className="download-modal-actions">
<button className="btn btn-ghost" onClick={cancel}>{t('common.cancel')}</button>
<button
className="btn btn-primary"
onClick={() => confirm(setDownloadFolder)}
disabled={!folder}
>
{t('common.download')}
</button>
</div>
</div>
</>
);
}
+98 -44
View File
@@ -1,7 +1,9 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, Trash2, RotateCcw } from 'lucide-react';
import CustomSelect from './CustomSelect';
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
import { useThemeStore } from '../store/themeStore';
// ─── Frequency response canvas ────────────────────────────────────────────────
@@ -11,31 +13,31 @@ const EQ_Q = 1.41;
function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number {
if (Math.abs(gainDb) < 0.01) return 0;
const w0 = (2 * Math.PI * centerHz) / sampleRate;
const A = Math.pow(10, gainDb / 40);
const A = Math.pow(10, gainDb / 40);
const alpha = Math.sin(w0) / (2 * EQ_Q);
const b0 = 1 + alpha * A;
const b0 = 1 + alpha * A;
const b1 = -2 * Math.cos(w0);
const b2 = 1 - alpha * A;
const a0 = 1 + alpha / A;
const b2 = 1 - alpha * A;
const a0 = 1 + alpha / A;
const a1 = -2 * Math.cos(w0);
const a2 = 1 - alpha / A;
const w = (2 * Math.PI * freq) / sampleRate;
const a2 = 1 - alpha / A;
const w = (2 * Math.PI * freq) / sampleRate;
const cosW = Math.cos(w), sinW = Math.sin(w);
const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w);
const numRe = b0 + b1 * cosW + b2 * cos2W;
const numIm = - b1 * sinW - b2 * sin2W;
const numIm = - b1 * sinW - b2 * sin2W;
const denRe = a0 + a1 * cosW + a2 * cos2W;
const denIm = - a1 * sinW - a2 * sin2W;
const denIm = - a1 * sinW - a2 * sin2W;
const numMag2 = numRe * numRe + numIm * numIm;
const denMag2 = denRe * denRe + denIm * denIm;
return 10 * Math.log10(numMag2 / denMag2);
}
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string) {
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) {
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth;
const H = canvas.offsetHeight;
canvas.width = W * dpr;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
@@ -52,7 +54,7 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
// Background
ctx.fillStyle = '#0d0d12';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, W, H);
// Grid: dB lines
@@ -64,7 +66,7 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
ctx.moveTo(padL, y);
ctx.lineTo(W - padR, y);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillStyle = textColor;
ctx.font = '9px monospace';
ctx.textAlign = 'right';
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
@@ -123,13 +125,70 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
ctx.stroke();
}
// ─── Custom vertical fader (no native range input) ────────────────────────────
const GAIN_MIN = -12, GAIN_MAX = 12;
interface FaderProps {
value: number;
disabled: boolean;
onChange: (v: number) => void;
}
function VerticalFader({ value, disabled, onChange }: FaderProps) {
const trackRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom
const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN);
const updateFromY = useCallback((clientY: number) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
}, [onChange]);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
dragging.current = true;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
updateFromY(e.clientY);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!dragging.current || disabled) return;
updateFromY(e.clientY);
};
const onPointerUp = () => { dragging.current = false; };
const thumbPct = gainToPct(value) * 100;
return (
<div
ref={trackRef}
className="eq-fader-custom"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{ cursor: disabled ? 'default' : 'pointer' }}
>
<div className="eq-track-line" />
<div className="eq-thumb" style={{ top: `${thumbPct}%`, opacity: disabled ? 0.3 : 1 }} />
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function Equalizer() {
const { t } = useTranslation();
const gains = useEqStore(s => s.gains);
const enabled = useEqStore(s => s.enabled);
const activePreset = useEqStore(s => s.activePreset);
const gains = useEqStore(s => s.gains);
const enabled = useEqStore(s => s.enabled);
const activePreset = useEqStore(s => s.activePreset);
const customPresets = useEqStore(s => s.customPresets);
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
@@ -137,13 +196,17 @@ export default function Equalizer() {
const [showSave, setShowSave] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useThemeStore(s => s.theme);
const redraw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
drawCurve(canvas, gains, accent);
}, [gains]);
const style = getComputedStyle(document.documentElement);
const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
drawCurve(canvas, gains, accent, bg, text);
}, [gains, theme]);
useEffect(() => { redraw(); }, [redraw]);
@@ -153,8 +216,8 @@ export default function Equalizer() {
return () => ro.disconnect();
}, [redraw]);
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
const handleSave = () => {
@@ -178,31 +241,26 @@ export default function Equalizer() {
</label>
<div className="eq-preset-row">
<select
className="input eq-preset-select"
<CustomSelect
className="eq-preset-select"
value={selectValue}
onChange={e => applyPreset(e.target.value)}
>
{activePreset === null && <option value="__custom__">{t('settings.eqPresetCustom')}</option>}
<optgroup label={t('settings.eqPresetBuiltin')}>
{BUILTIN_PRESETS.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
</optgroup>
{customPresets.length > 0 && (
<optgroup label={t('settings.eqPresetCustomGroup')}>
{customPresets.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
</optgroup>
)}
</select>
onChange={v => applyPreset(v)}
options={[
...(activePreset === null ? [{ value: '__custom__', label: t('settings.eqPresetCustom'), disabled: true }] : []),
...BUILTIN_PRESETS.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetBuiltin') })),
...customPresets.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetCustomGroup') })),
]}
/>
{isCustomSaved && (
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} title={t('settings.eqDeletePreset')}>
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} data-tooltip={t('settings.eqDeletePreset')}>
<Trash2 size={13} />
</button>
)}
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} title={t('settings.eqResetBands')}>
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} data-tooltip={t('settings.eqResetBands')}>
<RotateCcw size={13} />
</button>
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} title={t('settings.eqSavePreset')}>
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} data-tooltip={t('settings.eqSavePreset')}>
<Save size={13} />
</button>
</div>
@@ -245,14 +303,10 @@ export default function Equalizer() {
</span>
<div className="eq-fader-track">
<div className="eq-zero-mark" />
<input
type="range"
className="eq-fader"
min={-12} max={12} step={0.5}
<VerticalFader
value={gains[i]}
onChange={e => setBandGain(i, parseFloat(e.target.value))}
disabled={!enabled}
aria-label={`${band.label} Hz`}
onChange={v => setBandGain(i, v)}
/>
</div>
<span className="eq-freq-label">{band.label}</span>
-3
View File
@@ -193,9 +193,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<>
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
<div className="fs-orb fs-orb-1" aria-hidden="true" />
<div className="fs-orb fs-orb-2" aria-hidden="true" />
<div className="fs-orb fs-orb-3" aria-hidden="true" />
</>
)}
+7
View File
@@ -0,0 +1,7 @@
export default function LastfmIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
</svg>
);
}
+44 -2
View File
@@ -1,12 +1,14 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -20,11 +22,14 @@ function formatTime(seconds: number): string {
export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
const duration = currentTrack?.duration ?? 0;
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
@@ -84,6 +89,17 @@ export default function PlayerBar() {
{currentTrack?.artist ?? '—'}
</div>
</div>
{currentTrack && lastfmSessionKey && (
<button
className="player-btn player-btn-sm player-love-btn"
onClick={toggleLastfmLove}
aria-label={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
>
<Heart size={15} fill={lastfmLoved ? '#e31c23' : 'none'} />
</button>
)}
</div>
{/* Transport Controls */}
@@ -125,6 +141,16 @@ export default function PlayerBar() {
<span className="player-time">{formatTime(duration)}</span>
</div>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
onClick={() => setEqOpen(v => !v)}
aria-label="Equalizer"
data-tooltip="Equalizer"
>
<SlidersHorizontal size={15} />
</button>
{/* Volume */}
<div className="player-volume-section">
<button
@@ -149,6 +175,22 @@ export default function PlayerBar() {
/>
</div>
{/* EQ Popup */}
{eqOpen && (
<>
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
<div className="eq-popup">
<div className="eq-popup-header">
<span className="eq-popup-title">Equalizer</span>
<button className="eq-popup-close" onClick={() => setEqOpen(false)} aria-label="Close">
<X size={16} />
</button>
</div>
<Equalizer />
</div>
</>
)}
</footer>
);
}
+15 -10
View File
@@ -38,7 +38,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
if (isCollapsed) {
return (
<div className="update-toast-icon" style={{ marginTop: 'auto' }} title={`${t('sidebar.updateAvailable')}: ${latestVersion}`}>
<div className="update-toast-icon" style={{ marginTop: 'auto' }} data-tooltip={`${t('sidebar.updateAvailable')}: ${latestVersion}`} data-tooltip-pos="bottom">
<ArrowUpCircle size={20} />
</div>
);
@@ -100,9 +100,10 @@ export default function Sidebar({
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand">
<button
className="collapse-btn"
onClick={toggleCollapse}
title={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
className="collapse-btn"
onClick={toggleCollapse}
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
data-tooltip-pos="bottom"
style={{ padding: 0 }}
>
{isCollapsed ? <PanelLeft size={24} /> : <PanelLeftClose size={24} />}
@@ -119,7 +120,8 @@ export default function Sidebar({
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
title={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
@@ -130,7 +132,8 @@ export default function Sidebar({
<NavLink
to="/now-playing"
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.nowPlaying') : undefined}
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
data-tooltip-pos="bottom"
style={{ marginTop: 'auto' }}
>
<span className="nav-np-icon-wrap">
@@ -145,8 +148,8 @@ export default function Sidebar({
<NavLink
to="/statistics"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
title={isCollapsed ? t('sidebar.statistics') : undefined}
data-tooltip={isCollapsed ? t('sidebar.statistics') : undefined}
data-tooltip-pos="bottom"
>
<BarChart3 size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
@@ -154,7 +157,8 @@ export default function Sidebar({
<NavLink
to="/help"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.help') : undefined}
data-tooltip={isCollapsed ? t('sidebar.help') : undefined}
data-tooltip-pos="bottom"
>
<HelpCircle size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.help')}</span>}
@@ -162,7 +166,8 @@ export default function Sidebar({
<NavLink
to="/settings"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.settings') : undefined}
data-tooltip={isCollapsed ? t('sidebar.settings') : undefined}
data-tooltip-pos="bottom"
>
<Settings size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface TooltipState {
text: string;
anchorRect: DOMRect;
preferBottom: boolean;
wrap: boolean;
}
export default function TooltipPortal() {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
useEffect(() => {
const onOver = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
if (!target) return;
const text = target.getAttribute('data-tooltip');
if (!text) return;
setTooltip({
text,
anchorRect: target.getBoundingClientRect(),
preferBottom: target.getAttribute('data-tooltip-pos') === 'bottom',
wrap: target.hasAttribute('data-tooltip-wrap'),
});
};
const onOut = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-tooltip]');
if (target) setTooltip(null);
};
document.addEventListener('mouseover', onOver);
document.addEventListener('mouseout', onOut);
return () => {
document.removeEventListener('mouseover', onOver);
document.removeEventListener('mouseout', onOut);
};
}, []);
useLayoutEffect(() => {
if (!tooltip || !boxRef.current) { setStyle({ opacity: 0 }); return; }
const box = boxRef.current.getBoundingClientRect();
const { anchorRect, preferBottom } = tooltip;
const GAP = 7;
const MARGIN = 8;
// Decide top or bottom
const spaceAbove = anchorRect.top - GAP - box.height;
const useBottom = preferBottom || spaceAbove < MARGIN;
let top = useBottom
? anchorRect.bottom + GAP
: anchorRect.top - GAP - box.height;
// Clamp vertically
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
// Centre horizontally, clamp to viewport
let left = anchorRect.left + anchorRect.width / 2 - box.width / 2;
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
setStyle({ opacity: 1, top, left });
}, [tooltip]);
if (!tooltip) return null;
return createPortal(
<div
ref={boxRef}
style={{
position: 'fixed',
zIndex: 99999,
background: 'var(--bg-card)',
color: 'var(--text-primary)',
border: '1px solid var(--border-subtle)',
borderRadius: 'var(--radius-sm)',
padding: '4px 8px',
fontSize: '12px',
fontWeight: 500,
boxShadow: '0 4px 12px rgba(0,0,0,0.6)',
pointerEvents: 'none',
whiteSpace: tooltip.wrap ? 'pre-line' : 'nowrap',
maxWidth: tooltip.wrap ? '220px' : undefined,
transition: 'opacity 0.15s ease',
...style,
}}
>
{tooltip.text}
</div>,
document.body
);
}
+108 -10
View File
@@ -63,6 +63,8 @@ const enTranslation = {
addToQueue: 'Add to Queue',
enqueueAlbum: 'Enqueue Album',
startRadio: 'Start Radio',
lfmLove: 'Love on Last.fm',
lfmUnlove: 'Unlove on Last.fm',
favorite: 'Favorite',
favoriteArtist: 'Favorite Artist',
favoriteAlbum: 'Favorite Album',
@@ -123,6 +125,8 @@ const enTranslation = {
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
openedInBrowser: 'Opened in browser',
featuredOn: 'Also Featured On',
similarArtists: 'Similar Artists',
},
favorites: {
title: 'Favorites',
@@ -241,6 +245,10 @@ const enTranslation = {
use: 'Use',
add: 'Add',
active: 'Active',
download: 'Download',
chooseDownloadFolder: 'Choose download folder',
noFolderSelected: 'No folder selected',
rememberDownloadFolder: 'Remember this folder',
},
settings: {
title: 'Settings',
@@ -278,11 +286,16 @@ const enTranslation = {
eqPresetName: 'Preset name…',
eqDeletePreset: 'Delete preset',
eqResetBands: 'Reset to Flat',
lfmTitle: 'Last.fm Scrobbling',
lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the',
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
lfmDesc1b: 'in your browser, go to your profile, and connect your Last.fm account.',
lfmDesc2: 'Once that is done, Psysonic automatically forwards your currently playing songs to Navidrome, and they will appear on Last.fm.',
lfmTitle: 'Last.fm',
lfmConnect: 'Connect with Last.fm',
lfmConnecting: 'Waiting for authorisation…',
lfmConfirm: 'I have authorised the app',
lfmConnected: 'Connected as',
lfmDisconnect: 'Disconnect',
lfmConnectDesc: 'Connect your Last.fm account to enable scrobbling and Now Playing updates directly from Psysonic — no Navidrome configuration required.',
lfmOpenBrowser: 'A browser window will open. Authorise Psysonic on Last.fm, then click the button below.',
lfmScrobbles: '{{count}} scrobbles',
lfmMemberSince: 'Member since {{year}}',
scrobbleEnabled: 'Scrobbling enabled',
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
behavior: 'App Behavior',
@@ -294,6 +307,7 @@ const enTranslation = {
downloadsDefault: 'Default Downloads Folder',
pickFolder: 'Select',
pickFolderTitle: 'Select Download Folder',
clearFolder: 'Clear download folder',
logout: 'Logout',
aboutTitle: 'About Psysonic',
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
@@ -304,6 +318,7 @@ const enTranslation = {
aboutVersion: 'Version',
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
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).',
@@ -311,6 +326,22 @@ const enTranslation = {
randomMixBlacklistAdd: 'Add',
randomMixBlacklistEmpty: 'No custom keywords added yet.',
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
tabPlayback: 'Playback',
tabLibrary: 'Library',
tabAppearance: 'Appearance',
tabServer: 'Server',
tabAbout: 'About',
playbackTitle: 'Playback',
replayGain: 'Replay Gain',
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
replayGainMode: 'Mode',
replayGainTrack: 'Track',
replayGainAlbum: 'Album',
crossfade: 'Crossfade',
crossfadeDesc: 'Fade between tracks',
crossfadeSecs: '{{n}}s',
gapless: 'Gapless Playback',
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
},
help: {
title: 'Help',
@@ -417,6 +448,24 @@ const enTranslation = {
decadeAlbums_one: '{{count}} Album',
decadeAlbums_other: '{{count}} Albums',
decadeUnknown: 'Unknown',
lfmTitle: 'Last.fm Stats',
lfmTopArtists: 'Top Artists',
lfmTopAlbums: 'Top Albums',
lfmTopTracks: 'Top Tracks',
lfmPlays: '{{count}} plays',
lfmPeriodOverall: 'All Time',
lfmPeriod7day: '7 Days',
lfmPeriod1month: '1 Month',
lfmPeriod3month: '3 Months',
lfmPeriod6month: '6 Months',
lfmPeriod12month: '12 Months',
lfmNotConnected: 'Connect Last.fm in Settings to see your stats.',
lfmRecentTracks: 'Recent Scrobbles',
lfmNowPlaying: 'Now Playing',
lfmJustNow: 'just now',
lfmMinutesAgo: '{{n}}m ago',
lfmHoursAgo: '{{n}}h ago',
lfmDaysAgo: '{{n}}d ago',
},
player: {
regionLabel: 'Music Player',
@@ -504,6 +553,8 @@ const deTranslation = {
addToQueue: 'Zur Warteschlange hinzufügen',
enqueueAlbum: 'Ganzes Album einreihen',
startRadio: 'Radio starten',
lfmLove: 'Auf Last.fm liken',
lfmUnlove: 'Last.fm-Like entfernen',
favorite: 'Favorisieren',
favoriteArtist: 'Künstler favorisieren',
favoriteAlbum: 'Album favorisieren',
@@ -564,6 +615,8 @@ const deTranslation = {
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
openedInBrowser: 'Im Browser geöffnet',
featuredOn: 'Auch enthalten auf',
similarArtists: 'Ähnliche Künstler',
},
favorites: {
title: 'Favoriten',
@@ -682,6 +735,10 @@ const deTranslation = {
use: 'Verwenden',
add: 'Hinzufügen',
active: 'Aktiv',
download: 'Herunterladen',
chooseDownloadFolder: 'Download-Ordner wählen',
noFolderSelected: 'Kein Ordner ausgewählt',
rememberDownloadFolder: 'Ordner merken',
},
settings: {
title: 'Einstellungen',
@@ -719,11 +776,16 @@ const deTranslation = {
eqPresetName: 'Preset-Name…',
eqDeletePreset: 'Preset löschen',
eqResetBands: 'Auf Flat zurücksetzen',
lfmTitle: 'Last.fm Scrobbling',
lfmDesc1: 'Psysonic unterstützt serverseitiges Scrobbling direkt über Navidrome. Um Last.fm zu verknüpfen, logge dich bitte einmalig über den',
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
lfmDesc1b: 'im Browser ein, gehe auf dein Profil und verbinde deinen Last.fm Account.',
lfmDesc2: 'Sobald das erledigt ist, leitet Psysonic deine aktuell gespielten Songs automatisch an Navidrome weiter, und diese erscheinen auf Last.fm.',
lfmTitle: 'Last.fm',
lfmConnect: 'Mit Last.fm verbinden',
lfmConnecting: 'Warte auf Bestätigung…',
lfmConfirm: 'Ich habe die App autorisiert',
lfmConnected: 'Verbunden als',
lfmDisconnect: 'Trennen',
lfmConnectDesc: 'Verbinde deinen Last.fm Account, um Scrobbling und Now-Playing-Updates direkt aus Psysonic heraus zu aktivieren — keine Navidrome-Konfiguration erforderlich.',
lfmOpenBrowser: 'Es öffnet sich ein Browserfenster. Autorisiere Psysonic auf Last.fm und klicke dann unten auf den Button.',
lfmScrobbles: '{{count}} Scrobbles',
lfmMemberSince: 'Dabei seit {{year}}',
scrobbleEnabled: 'Scrobbling aktiviert',
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
behavior: 'App-Verhalten',
@@ -735,6 +797,7 @@ const deTranslation = {
downloadsDefault: 'Standard-Downloads-Ordner',
pickFolder: 'Auswählen',
pickFolderTitle: 'Download-Ordner auswählen',
clearFolder: 'Download-Ordner löschen',
logout: 'Abmelden',
aboutTitle: 'Über Psysonic',
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
@@ -745,6 +808,7 @@ const deTranslation = {
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
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).',
@@ -752,6 +816,22 @@ const deTranslation = {
randomMixBlacklistAdd: 'Hinzufügen',
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
tabPlayback: 'Wiedergabe',
tabLibrary: 'Bibliothek',
tabAppearance: 'Darstellung',
tabServer: 'Server',
tabAbout: 'Info',
playbackTitle: 'Wiedergabe',
replayGain: 'Replay Gain',
replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren',
replayGainMode: 'Modus',
replayGainTrack: 'Track',
replayGainAlbum: 'Album',
crossfade: 'Crossfade',
crossfadeDesc: 'Überblendung zwischen Tracks',
crossfadeSecs: '{{n}}s',
gapless: 'Nahtlose Wiedergabe',
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
},
help: {
title: 'Hilfe',
@@ -858,6 +938,24 @@ const deTranslation = {
decadeAlbums_one: '{{count}} Album',
decadeAlbums_other: '{{count}} Alben',
decadeUnknown: 'Unbekannt',
lfmTitle: 'Last.fm Statistiken',
lfmTopArtists: 'Top-Künstler',
lfmTopAlbums: 'Top-Alben',
lfmTopTracks: 'Top-Titel',
lfmPlays: '{{count}} Plays',
lfmPeriodOverall: 'Gesamt',
lfmPeriod7day: '7 Tage',
lfmPeriod1month: '1 Monat',
lfmPeriod3month: '3 Monate',
lfmPeriod6month: '6 Monate',
lfmPeriod12month: '12 Monate',
lfmNotConnected: 'Verbinde Last.fm in den Einstellungen, um deine Statistiken zu sehen.',
lfmRecentTracks: 'Zuletzt gescrobbelt',
lfmNowPlaying: 'Läuft gerade',
lfmJustNow: 'gerade eben',
lfmMinutesAgo: 'vor {{n}} Min.',
lfmHoursAgo: 'vor {{n}} Std.',
lfmDaysAgo: 'vor {{n}} Tagen',
},
player: {
regionLabel: 'Musikplayer',
+10 -14
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import AlbumCard from '../components/AlbumCard';
@@ -24,6 +25,7 @@ export default function AlbumDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
@@ -106,6 +108,11 @@ export default function AlbumDetail() {
const handleDownload = async () => {
if (!album) return;
const { name, id: albumId } = album.album;
// Ask for folder before starting download if not already set
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
setDownloadProgress(0);
try {
const url = buildDownloadUrl(albumId);
@@ -133,20 +140,9 @@ export default function AlbumDetail() {
}
const blob = new Blob(chunks);
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${sanitizeFilename(name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} else {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${sanitizeFilename(name)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} catch (e) {
console.error('Download failed:', e);
setDownloadProgress(null);
+102 -11
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -31,13 +33,6 @@ function sanitizeHtml(html: string): string {
return doc.body.innerHTML;
}
function LastfmIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
</svg>
);
}
export default function ArtistDetail() {
const { t } = useTranslation();
@@ -45,12 +40,15 @@ export default function ArtistDetail() {
const navigate = useNavigate();
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
const [loading, setLoading] = useState(true);
const [radioLoading, setRadioLoading] = useState(false);
const [isStarred, setIsStarred] = useState(false);
const [openedLink, setOpenedLink] = useState<string | null>(null);
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
const [similarLoading, setSimilarLoading] = useState(false);
const playTrack = usePlayerStore(state => state.playTrack);
const enqueue = usePlayerStore(state => state.enqueue);
@@ -60,17 +58,45 @@ export default function ArtistDetail() {
useEffect(() => {
if (!id) return;
setLoading(true);
let ownAlbumIds: Set<string>;
getArtist(id).then(artistData => {
setArtist(artistData.artist);
setAlbums(artistData.albums);
setIsStarred(!!artistData.artist.starred);
ownAlbumIds = new Set(artistData.albums.map(a => a.id));
return Promise.all([
getArtistInfo(id).catch(() => null),
getTopSongs(artistData.artist.name).catch(() => [])
getTopSongs(artistData.artist.name).catch(() => []),
search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })),
]);
}).then(([artistInfo, songsData]) => {
}).then(([artistInfo, songsData, searchResults]) => {
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
const featuredSongs = (searchResults.songs ?? []).filter(
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
);
const albumMap = new Map<string, SubsonicAlbum>();
featuredSongs.forEach(song => {
if (!albumMap.has(song.albumId)) {
albumMap.set(song.albumId, {
id: song.albumId,
name: song.album,
artist: song.albumArtist ?? '',
artistId: '',
coverArt: song.coverArt,
songCount: 1,
duration: song.duration,
year: song.year,
});
} else {
const a = albumMap.get(song.albumId)!;
a.songCount++;
a.duration += song.duration;
}
});
setFeaturedAlbums([...albumMap.values()]);
setLoading(false);
}).catch(err => {
console.error(err);
@@ -78,6 +104,32 @@ export default function ArtistDetail() {
});
}, [id]);
useEffect(() => {
if (!artist || !lastfmIsConfigured()) return;
setSimilarArtists([]);
setSimilarLoading(true);
lastfmGetSimilarArtists(artist.name).then(async names => {
if (names.length === 0) { setSimilarLoading(false); return; }
const results = await Promise.all(
names.slice(0, 30).map(name =>
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
)
);
const seen = new Set<string>([artist.id]);
const found: SubsonicArtist[] = [];
for (let i = 0; i < results.length; i++) {
const targetName = names[i].toLowerCase();
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
if (match && !seen.has(match.id)) {
seen.add(match.id);
found.push(match);
}
}
setSimilarArtists(found);
setSimilarLoading(false);
}).catch(() => setSimilarLoading(false));
}, [artist?.id]);
const openLink = (url: string, key: string) => {
open(url);
setOpenedLink(key);
@@ -296,8 +348,35 @@ export default function ArtistDetail() {
</>
)}
{/* Similar Artists (Last.fm) */}
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
<>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.similarArtists')}
</h2>
{similarLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
{t('artistDetail.loading')}
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{similarArtists.map(a => (
<button
key={a.id}
className="artist-ext-link"
onClick={() => navigate(`/artist/${a.id}`)}
>
{a.name}
</button>
))}
</div>
)}
</>
)}
{/* Albums */}
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0) ? '2rem' : '0', marginBottom: '1rem' }}>
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}>
{t('artistDetail.albumsBy', { name: artist.name })}
</h2>
@@ -308,6 +387,18 @@ export default function ArtistDetail() {
) : (
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
)}
{/* Also Featured On */}
{featuredAlbums.length > 0 && (
<>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.featuredOn')}
</h2>
<div className="album-grid-wrap">
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
</>
)}
</div>
);
}
+6 -9
View File
@@ -71,9 +71,6 @@ const NpBg = memo(function NpBg({ url }: { url: string }) {
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
/>
))}
<div className="np-orb np-orb-1" />
<div className="np-orb np-orb-2" />
<div className="np-orb np-orb-3" />
<div className="np-bg-overlay" />
</div>
);
@@ -319,7 +316,7 @@ export default function NowPlaying() {
<button
onClick={toggleStar}
className="np-star-btn"
title={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Star size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
</button>
@@ -347,16 +344,16 @@ export default function NowPlaying() {
)}
</div>
<div className="np-queue-actions">
<button onClick={() => shuffleQueue()} className="np-action-btn" title={t('queue.shuffle')} disabled={queue.length < 2}>
<button onClick={() => shuffleQueue()} className="np-action-btn" data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
<Shuffle size={15} />
</button>
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" title={t('queue.save')} disabled={queue.length === 0}>
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.save')} disabled={queue.length === 0}>
<Save size={15} />
</button>
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" title={t('queue.load')}>
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.load')}>
<FolderOpen size={15} />
</button>
<button onClick={clearQueue} className="np-action-btn" title={t('queue.clear')} disabled={queue.length === 0}>
<button onClick={clearQueue} className="np-action-btn" data-tooltip={t('queue.clear')} disabled={queue.length === 0}>
<Trash2 size={15} />
</button>
</div>
@@ -412,7 +409,7 @@ export default function NowPlaying() {
<button
className="np-queue-remove"
onClick={e => { e.stopPropagation(); removeTrack(idx); }}
title={t('queue.remove')}
data-tooltip={t('queue.remove')}
><X size={13} /></button>
</div>
);
+673 -353
View File
File diff suppressed because it is too large Load Diff
+157 -33
View File
@@ -1,34 +1,87 @@
import React, { useEffect, useState } from 'react';
import { getAlbumList, getArtists, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
import { getAlbumList, getArtists, getGenres, SubsonicAlbum } from '../api/subsonic';
import AlbumRow from '../components/AlbumRow';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
function relativeTime(timestamp: number, t: (key: string, opts?: object) => string): string {
const diff = Math.floor(Date.now() / 1000) - timestamp;
if (diff < 60) return t('statistics.lfmJustNow');
if (diff < 3600) return t('statistics.lfmMinutesAgo', { n: Math.floor(diff / 60) });
if (diff < 86400) return t('statistics.lfmHoursAgo', { n: Math.floor(diff / 3600) });
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
}
const PERIODS: { key: LastfmPeriod; label: string }[] = [
{ key: '7day', label: 'lfmPeriod7day' },
{ key: '1month', label: 'lfmPeriod1month' },
{ key: '3month', label: 'lfmPeriod3month' },
{ key: '6month', label: 'lfmPeriod6month' },
{ key: '12month', label: 'lfmPeriod12month' },
{ key: 'overall', label: 'lfmPeriodOverall' },
];
export default function Statistics() {
const { t } = useTranslation();
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [artistCount, setArtistCount] = useState<number | null>(null);
const [totalSongs, setTotalSongs] = useState<number | null>(null);
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month');
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
const [lfmTopTracks, setLfmTopTracks] = useState<LastfmTopTrack[]>([]);
const [lfmLoading, setLfmLoading] = useState(false);
const [lfmRecentTracks, setLfmRecentTracks] = useState<LastfmRecentTrack[]>([]);
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
useEffect(() => {
Promise.all([
getAlbumList('recent', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('highest', 12).catch(() => []),
getGenres().catch(() => []),
getArtists().catch(() => []),
]).then(([rc, fr, hi, g, a]) => {
getGenres().catch(() => []),
]).then(([rc, fr, hi, a, g]) => {
setRecent(rc);
setFrequent(fr);
setHighest(hi);
setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20));
setArtistCount(a.length);
setTotalSongs(g.reduce((acc: number, genre: any) => acc + genre.songCount, 0));
setTotalAlbums(g.reduce((acc: number, genre: any) => acc + genre.albumCount, 0));
setLoading(false);
}).catch(() => setLoading(false));
}, []);
useEffect(() => {
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
setLfmRecentLoading(true);
lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20)
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
.catch(() => setLfmRecentLoading(false));
}, [lastfmSessionKey, lastfmUsername]);
useEffect(() => {
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
setLfmLoading(true);
Promise.all([
lastfmGetTopArtists(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
lastfmGetTopAlbums(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
lastfmGetTopTracks(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
]).then(([artists, albums, tracks]) => {
setLfmTopArtists(artists);
setLfmTopAlbums(albums);
setLfmTopTracks(tracks);
setLfmLoading(false);
}).catch(() => setLfmLoading(false));
}, [lfmPeriod, lastfmSessionKey, lastfmUsername]);
const loadMore = async (
type: 'frequent' | 'highest',
currentList: SubsonicAlbum[],
@@ -43,15 +96,10 @@ export default function Statistics() {
}
};
const totalSongs = genres.reduce((acc, g) => acc + g.songCount, 0);
const totalAlbums = genres.reduce((acc, g) => acc + g.albumCount, 0);
const maxGenreCount = Math.max(...genres.map(g => g.songCount), 1);
const stats = [
{ label: t('statistics.statArtists'), value: artistCount },
{ label: t('statistics.statAlbums'), value: totalAlbums || null },
{ label: t('statistics.statSongs'), value: totalSongs || null },
{ label: t('statistics.statGenres'), value: genres.length || null },
{ label: t('statistics.statAlbums'), value: totalAlbums },
{ label: t('statistics.statSongs'), value: totalSongs },
];
return (
@@ -90,29 +138,105 @@ export default function Statistics() {
moreText={t('statistics.loadMore')}
/>
{genres.length > 0 && (
<section>
<h2 className="section-title">{t('statistics.genreDistribution')}</h2>
<div className="genre-chart">
{genres.map(genre => (
<div key={genre.value} className="genre-row">
<div className="genre-row-header">
<span className="genre-name">{genre.value}</span>
<span className="genre-counts">
{t('statistics.genreSongs', { count: genre.songCount })}
{' · '}
{t('statistics.genreAlbums', { count: genre.albumCount })}
{/* Last.fm Stats */}
{lastfmIsConfigured() && (
<section style={{ marginTop: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('statistics.lfmTitle')}</h2>
{lastfmSessionKey && (
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
{PERIODS.map(p => (
<button
key={p.key}
className={`btn btn-sm ${lfmPeriod === p.key ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setLfmPeriod(p.key)}
style={{ padding: '0.25rem 0.625rem', fontSize: '0.75rem' }}
>
{t(`statistics.${p.label}`)}
</button>
))}
</div>
)}
</div>
{!lastfmSessionKey ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{t('statistics.lfmNotConnected')}</p>
) : lfmLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
{([
{ label: t('statistics.lfmTopArtists'), items: lfmTopArtists.map(a => ({ primary: a.name, secondary: null, playcount: a.playcount })) },
{ label: t('statistics.lfmTopAlbums'), items: lfmTopAlbums.map(a => ({ primary: a.name, secondary: a.artist, playcount: a.playcount })) },
{ label: t('statistics.lfmTopTracks'), items: lfmTopTracks.map(tr => ({ primary: tr.name, secondary: tr.artist, playcount: tr.playcount })) },
] as { label: string; items: { primary: string; secondary: string | null; playcount: string }[] }[]).map(col => {
const max = Math.max(...col.items.map(it => Number(it.playcount)), 1);
return (
<div key={col.label} style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
{col.label}
</h3>
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
{col.items.map((it, i) => (
<li key={`${it.primary}-${i}`}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.625rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
{i + 1}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.primary}</div>
{it.secondary && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.secondary}</div>
)}
</div>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0 }}>{Number(it.playcount).toLocaleString()}</span>
</div>
<div style={{ height: '2px', borderRadius: '1px', background: 'var(--glass-border)', overflow: 'hidden', marginLeft: '2.125rem' }}>
<div style={{ height: '100%', width: `${(Number(it.playcount) / max) * 100}%`, background: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 0.8 : 0.3, borderRadius: '1px', transition: 'width 0.4s ease' }} />
</div>
</li>
))}
</ol>
</div>
);
})}
</div>
)}
</section>
)}
{/* Recent Scrobbles */}
{lastfmIsConfigured() && lastfmSessionKey && (
<section style={{ marginTop: '2rem' }}>
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('statistics.lfmRecentTracks')}</h2>
{lfmRecentLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{lfmRecentTracks.map((track, i) => (
<div key={`${track.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 0.75rem', borderRadius: '8px', background: track.nowPlaying ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', border: track.nowPlaying ? '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' : '1px solid transparent' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
{track.nowPlaying && (
<span style={{ fontSize: '0.65rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--accent)', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{track.artist}{track.album ? ` · ${track.album}` : ''}
</div>
</div>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0 }}>
{track.nowPlaying ? '' : track.timestamp ? relativeTime(track.timestamp, t) : ''}
</span>
</div>
<div className="genre-bar-track">
<div
className="genre-bar-fill"
style={{ width: `${(genre.songCount / maxGenreCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
))}
</div>
)}
</section>
)}
+28
View File
@@ -27,6 +27,11 @@ interface AuthState {
downloadFolder: string;
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
replayGainEnabled: boolean;
replayGainMode: 'track' | 'album';
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
// Status
isLoggedIn: boolean;
@@ -42,12 +47,19 @@ interface AuthState {
setConnecting: (v: boolean) => void;
setConnectionError: (e: string | null) => void;
setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void;
connectLastfm: (sessionKey: string, username: string) => void;
disconnectLastfm: () => void;
setMinimizeToTray: (v: boolean) => void;
setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void;
setDownloadFolder: (v: string) => void;
setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
setReplayGainMode: (v: 'track' | 'album') => void;
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
logout: () => void;
// Derived
@@ -74,6 +86,11 @@ export const useAuthStore = create<AuthState>()(
downloadFolder: '',
excludeAudiobooks: false,
customGenreBlacklist: [],
replayGainEnabled: false,
replayGainMode: 'track',
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -111,12 +128,23 @@ export const useAuthStore = create<AuthState>()(
setLastfm: (apiKey, apiSecret, sessionKey, username) =>
set({ lastfmApiKey: apiKey, lastfmApiSecret: apiSecret, lastfmSessionKey: sessionKey, lastfmUsername: username }),
connectLastfm: (sessionKey, username) =>
set({ lastfmSessionKey: sessionKey, lastfmUsername: username }),
disconnectLastfm: () =>
set({ lastfmSessionKey: '', lastfmUsername: '' }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
setReplayGainEnabled: (v) => set({ replayGainEnabled: v }),
setReplayGainMode: (v) => set({ replayGainMode: v }),
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
logout: () => set({ isLoggedIn: false }),
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand';
// Module-level callback — not in Zustand state to avoid serialization issues
let _resolve: ((folder: string | null) => void) | null = null;
interface DownloadModalStore {
isOpen: boolean;
folder: string;
remember: boolean;
requestFolder: () => Promise<string | null>;
setFolder: (f: string) => void;
setRemember: (r: boolean) => void;
confirm: (setDownloadFolder: (v: string) => void) => void;
cancel: () => void;
}
export const useDownloadModalStore = create<DownloadModalStore>((set, get) => ({
isOpen: false,
folder: '',
remember: false,
requestFolder: () =>
new Promise<string | null>(resolve => {
_resolve = resolve;
set({ isOpen: true, folder: '', remember: false });
}),
setFolder: (folder) => set({ folder }),
setRemember: (remember) => set({ remember }),
confirm: (setDownloadFolder) => {
const { folder, remember } = get();
if (!folder) return;
if (remember) setDownloadFolder(folder);
_resolve?.(folder);
_resolve = null;
set({ isOpen: false });
},
cancel: () => {
_resolve?.(null);
_resolve = null;
set({ isOpen: false });
},
}));
+147 -4
View File
@@ -2,7 +2,8 @@ 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, SubsonicSong, reportNowPlaying, scrobbleSong } from '../api/subsonic';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, SubsonicSong } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
export interface Track {
@@ -19,6 +20,30 @@ export interface Track {
bitRate?: number;
suffix?: string;
userRating?: number;
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
replayGainPeak?: number;
}
export function songToTrack(song: SubsonicSong): Track {
return {
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,
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
};
}
interface PlayerState {
@@ -31,6 +56,8 @@ interface PlayerState {
currentTime: number;
volume: number;
scrobbled: boolean;
lastfmLoved: boolean;
lastfmLovedCache: Record<string, boolean>;
playTrack: (track: Track, queue?: Track[]) => void;
pause: () => void;
@@ -59,6 +86,11 @@ interface PlayerState {
removeTrack: (index: number) => void;
shuffleQueue: () => void;
toggleLastfmLove: () => void;
setLastfmLoved: (v: boolean) => void;
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
syncLastfmLovedTracks: () => Promise<void>;
initializeFromServerQueue: () => Promise<void>;
contextMenu: {
@@ -119,11 +151,27 @@ function handleAudioProgress(current_time: number, duration: number) {
const progress = current_time / dur;
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
// Scrobble at 50%
// Scrobble at 50% directly via Last.fm
if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (scrobblingEnabled && lastfmSessionKey) {
lastfmScrobble(track, Date.now(), lastfmSessionKey);
}
}
// Gapless preload: buffer next track when 30s remain
const { gaplessEnabled } = useAuthStore.getState();
if (gaplessEnabled && dur - current_time < 30 && dur - current_time > 0) {
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
const nextTrack = repeatMode === 'one'
? track
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
if (nextTrack && nextTrack.id !== track.id) {
const nextUrl = buildStreamUrl(nextTrack.id);
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
}
}
}
@@ -167,7 +215,23 @@ export function initAudioListeners(): () => void {
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
];
// Sync Last.fm loved tracks cache on startup.
usePlayerStore.getState().syncLastfmLovedTracks();
// Initial sync of crossfade settings to Rust audio engine on startup.
const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
// Keep crossfade settings in sync whenever auth store changes.
const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs,
}).catch(() => {});
});
return () => {
unsubAuth();
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
@@ -186,6 +250,8 @@ export const usePlayerStore = create<PlayerState>()(
currentTime: 0,
volume: 0.8,
scrobbled: false,
lastfmLoved: false,
lastfmLovedCache: {},
isQueueVisible: true,
isFullscreenOpen: false,
repeatMode: 'off',
@@ -202,6 +268,55 @@ export const usePlayerStore = create<PlayerState>()(
setQueueVisible: (v: boolean) => set({ isQueueVisible: v }),
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
toggleLastfmLove: () => {
const { currentTrack, lastfmLoved } = get();
const { lastfmSessionKey } = useAuthStore.getState();
if (!currentTrack || !lastfmSessionKey) return;
const newLoved = !lastfmLoved;
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
set(s => ({ lastfmLoved: newLoved, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: newLoved } }));
if (newLoved) {
lastfmLoveTrack(currentTrack, lastfmSessionKey);
} else {
lastfmUnloveTrack(currentTrack, lastfmSessionKey);
}
},
setLastfmLoved: (v) => {
const { currentTrack } = get();
if (currentTrack) {
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
set(s => ({ lastfmLoved: v, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v } }));
} else {
set({ lastfmLoved: v });
}
},
syncLastfmLovedTracks: async () => {
const { lastfmSessionKey, lastfmUsername } = useAuthStore.getState();
if (!lastfmSessionKey || !lastfmUsername) return;
const tracks = await lastfmGetAllLovedTracks(lastfmUsername, lastfmSessionKey);
const newCache: Record<string, boolean> = {};
for (const t of tracks) newCache[`${t.title}::${t.artist}`] = true;
// Merge with existing cache (local likes take precedence)
set(s => ({ lastfmLovedCache: { ...newCache, ...s.lastfmLovedCache } }));
// Update current track's loved state if it's in the new cache
const { currentTrack } = get();
if (currentTrack) {
const loved = newCache[`${currentTrack.title}::${currentTrack.artist}`] ?? false;
set({ lastfmLoved: loved });
}
},
setLastfmLovedForSong: (title, artist, v) => {
const cacheKey = `${title}::${artist}`;
const isCurrentTrack = get().currentTrack?.title === title && get().currentTrack?.artist === artist;
set(s => ({
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v },
...(isCurrentTrack ? { lastfmLoved: v } : {}),
}));
},
toggleRepeat: () => set(state => {
const modes = ['off', 'all', 'one'] as const;
return { repeatMode: modes[(modes.indexOf(state.repeatMode) + 1) % modes.length] };
@@ -234,14 +349,22 @@ export const usePlayerStore = create<PlayerState>()(
buffered: 0,
currentTime: 0,
scrobbled: false,
lastfmLoved: false,
isPlaying: true, // optimistic — reverted on error
});
const url = buildStreamUrl(track.id);
const authState = useAuthStore.getState();
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
invoke('audio_play', {
url,
volume: state.volume,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play failed:', err);
@@ -252,7 +375,19 @@ export const usePlayerStore = create<PlayerState>()(
}, 500);
});
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
reportNowPlaying(track.id);
const { scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
if (lfmKey) {
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
const cacheKey = `${track.title}::${track.artist}`;
usePlayerStore.setState(s => ({
lastfmLoved: loved,
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
}));
});
}
syncQueueToServer(newQueue, track, 0);
},
@@ -277,10 +412,17 @@ export const usePlayerStore = create<PlayerState>()(
const gen = ++playGeneration;
const vol = get().volume;
set({ isPlaying: true });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = authStateCold.replayGainEnabled
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
invoke('audio_play', {
url: buildStreamUrl(currentTrack.id),
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
@@ -456,6 +598,7 @@ export const usePlayerStore = create<PlayerState>()(
queue: state.queue,
queueIndex: state.queueIndex,
currentTime: state.currentTime,
lastfmLovedCache: state.lastfmLovedCache,
} as Partial<PlayerState>),
}
)
+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';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave';
interface ThemeState {
theme: Theme;
+1408 -430
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -489,6 +489,74 @@
min-width: 0;
}
.player-eq-btn {
color: var(--text-muted);
flex-shrink: 0;
transition: color 0.15s;
}
.player-eq-btn:hover,
.player-eq-btn.active {
color: var(--accent);
}
/* ─── EQ Popup ─── */
.eq-popup-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
}
.eq-popup {
position: fixed;
z-index: 201;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(700px, 92vw);
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.eq-popup .eq-wrap {
max-width: none;
}
.eq-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.eq-popup-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.eq-popup-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--bg-hover);
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.eq-popup-close:hover {
background: var(--accent);
color: var(--bg-app);
}
/* ─── Queue Panel ─── */
.queue-panel {
+55
View File
@@ -422,6 +422,61 @@
--danger: #bf616a;
}
/* ─── Psychowave — Synthwave / Retrowave ─── */
[data-theme='psychowave'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c9a8ff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* ── Palette ── */
--ctp-crust: #120720;
--ctp-mantle: #160a28;
--ctp-base: #1c0b35;
--ctp-surface0: #2a1050;
--ctp-surface1: #381860;
--ctp-surface2: #4a2878;
--ctp-overlay0: #5e3d90;
--ctp-overlay1: #7b50b8;
--ctp-overlay2: #9868d8;
/* Soft lavender text */
--ctp-text: #f0e6ff;
--ctp-subtext1: #c9a8ff;
--ctp-subtext0: #a882e0;
/* Neon accents */
--ctp-mauve: #ff2d78; /* hot pink — primary accent & button gradient start */
--ctp-lavender: #bf5fff; /* electric purple — button gradient end */
--ctp-pink: #ff2d78;
--ctp-flamingo: #ff6b9d;
--ctp-rosewater: #ff8fc0;
--ctp-blue: #00d4ff; /* electric cyan */
--ctp-sapphire: #007aff;
--ctp-sky: #00bfff;
--ctp-teal: #00e5cc;
--ctp-green: #39ff14; /* neon green */
--ctp-yellow: #ffd700; /* neon gold */
--ctp-peach: #ff8c42;
--ctp-maroon: #ff4d6d;
--ctp-red: #ff2d55;
/* ── Semantic tokens ── */
--bg-app: #1c0b35;
--bg-sidebar: #160a28;
--bg-card: #2a1050;
--bg-hover: #381860;
--bg-player: #120720;
--bg-glass: rgba(28, 11, 53, 0.82);
--accent: #ff2d78;
--accent-dim: rgba(255, 45, 120, 0.15);
--accent-glow: rgba(255, 45, 120, 0.45);
--text-primary: #f0e6ff;
--text-secondary:#c9a8ff;
--text-muted: #9b72d0;
--border: #4a2878;
--border-subtle: #2a1050;
--positive: #39ff14;
--warning: #ffd700;
--danger: #ff2d55;
}
/* ─── Global Base Settings ─── */
:root {
/* Typography */
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />