Compare commits

...

34 Commits

Author SHA1 Message Date
Psychotoxical f45892e975 chore(deps): fix npm audit vulnerabilities (axios, vite)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:29:02 +02:00
Psychotoxical 16c511c167 fix(audio): suppress unused variable warning in DecodeError match arm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:27:40 +02:00
Psychotoxical 31f72b0459 Update CHANGELOG.md 2026-04-10 17:18:55 +02:00
Psychotoxical e060737de9 chore(aur): bump PKGBUILD to 1.34.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:15:54 +02:00
Psychotoxical 28b23a9de1 docs(changelog): add v1.34.8 release notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:13:40 +02:00
Psychotoxical 649f5223b4 chore: bump version to 1.34.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:06:19 +02:00
Psychotoxical 1cc674e4ee fix(ui): icon updates, remove mic button, theme fixes
- LiveSearch: replace SlidersVertical with TextSearch for advanced search
- AlbumHeader: add Highlighter icon for artist bio button
- PlayerBar: remove unused lyrics/mic button
- components.css: album-detail-badge always opaque (accent bg, white text)
- theme: Middle Earth — remove sidebar stripes, fix queue/artist/bio contrast
- theme: Toy Tale — fix muted text, queue tabs, sidebar labels, divider
- theme: Tetrastack — brighten purple/blue palette, raise text-muted contrast
- theme: Horde & Alliance — remove repeating sidebar line pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:05:20 +02:00
Psychotoxical 41e98d5783 fix(lyrics): strip Netease metadata lines from LRC output
Filter lines matching 作词/作曲/编曲/制作人/etc. that Netease
embeds as timestamped LRC entries at the start of the song.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:13:35 +02:00
Psychotoxical 6ffcd6f6fa feat(lyrics): add Netease Cloud Music as opt-in fallback source
Netease is queried only when server and LRCLIB both return nothing,
preserving the existing lyrics chain completely. Off by default.

- Rust command `fetch_netease_lyrics` proxies Netease API (CORS bypass)
- `src/api/netease.ts` TypeScript wrapper via invoke
- `authStore.enableNeteaselyrics` toggle (default: false)
- `useLyrics`: Netease fires last in fallback chain when enabled
- Settings toggle in the Lyrics section
- `lyricsSourceNetease` label in LyricsPane
- i18n: all 7 languages (en, de, fr, nl, nb, ru, zh)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:11:06 +02:00
Psychotoxical 33a15fd17a chore(settings): add AudioMuse-AI PR #147 to cucadmuh's contributions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:51:14 +02:00
Psychotoxical 084543e59b fix(tracklist): split multi-artist tracks and fix reset button style
- Use OpenSubsonic `artists[]` array to render each artist separately
  with · separator; artists with an ID are clickable, others plain text
- Fall back to single artist (artistId/artist) on non-OpenSubsonic servers
- Settings reset-to-defaults buttons changed from btn-ghost to btn-danger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:37:29 +02:00
Psychotoxical b0081e3d7a Merge pull request #147 from cucadmuh/feat/audiomuse-navidrome
feat(discovery): Navidrome AudioMuse-AI (Instant Mix, similar artists, probe)
2026-04-10 14:09:21 +02:00
Psychotoxical ccc9f2cae5 Merge pull request #147: feat(discovery): Navidrome AudioMuse-AI integration
Resolves conflict in ContextMenu.tsx: keep useShallow import from main
alongside getSimilarSongs added by this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:09:13 +02:00
Psychotoxical f34bd7c0f1 fix(audio): separate gapless chain from preload gate and reduce black flash
- Gapless now always chains at 30s regardless of preloadMode ('off' no
  longer breaks gapless playback)
- Byte pre-download (audio_preload) skips when Hot Cache is active to
  avoid duplicate downloads
- When both Gapless and Preload are active, bytes are pre-fetched early
  and the chain reuses the cached data (separate bytePreloadingId guard)
- player-album-art-wrap gets background: var(--bg-card) so the brief
  opacity-0 loading state shows a themed colour instead of black

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:04:30 +02:00
Psychotoxical 8cb5eb9384 chore(settings): remove alpha badges from Hot Cache and Hi-Res sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:45:56 +02:00
Psychotoxical 02a4c43f61 docs: add psysonic-bin AUR badge and early-development warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:41:51 +02:00
Psychotoxical c49f6af38b perf: reduce CPU usage and unify next-track buffering settings
- Throttle audio:progress from 100ms to 500ms; WaveformSeek and
  FsSeekbar use imperative DOM updates instead of React re-renders
- Fix all usePlayerStore() calls without selectors across pages and
  components (useShallow / individual selectors throughout)
- Remove filter:blur and transform:scale from Hero, AlbumDetail and
  FullscreenPlayer backgrounds — eliminated expensive software
  compositing layers on WebKitGTK
- Replace translate3d with 2D translate in FS mesh and portrait
  animations; remove will-change:transform from mesh blobs
- Move Hot Cache section from Storage tab to Audio tab; group Preload
  and Hot Cache under a shared 'Next Track Buffering' section with a
  mutual-exclusivity note and auto-disable logic
- Add 'off' toggle to Preload (replaces Off button with toggle switch);
  enabling either method now automatically disables the other

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:38:33 +02:00
Maxim Isaev 69c0c5a907 merge: integrate origin/main into feat/audiomuse-navidrome 2026-04-10 13:04:34 +03:00
Maxim Isaev 4de577eede feat(navidrome): Instant Mix probe, ping identity, and AudioMuse UX
Parse type and serverVersion from Subsonic ping; persist per-server identity and
run a background Instant Mix probe (random songs + getSimilarSongs) for
Navidrome 0.60+. Hide the AudioMuse server toggle when the probe returns no
similar tracks; clear prefs when the server is no longer eligible.

Artist pages fall back to Last.fm similar artists when AudioMuse is enabled but
the server returns none. Instant Mix failures set a per-server issue flag with a
settings warning and toast. Settings description links the official plugin repo
via i18n Trans.
2026-04-10 13:02:46 +03:00
Psychotoxical c9dfbcc19f chore(settings): reset uiScale to 1.0 on startup while scaling is disabled
Users who had previously set a custom scale are silently reset to 100%
on next launch so they are not stuck at a broken zoom level. The stored
value is cleared in fontStore so the slider starts at 100% once the
feature is re-enabled.

Also credits nisrael for the ICY / AzuraCast PR #146.

TODO: remove the reset effect and re-enable the slider in Settings.tsx
when UI scaling is properly reworked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:17:43 +02:00
Maxim Isaev 36f3d42dbe feat(discovery): Navidrome AudioMuse-AI client integration and library scoping
Add per-server toggle for AudioMuse-style discovery: Instant Mix via
getSimilarSongs, server similar artists instead of Last.fm on artist pages,
and higher similar-artist count in Now Playing when enabled.

When browsing a single music folder, filter similar/top song results by
album ids from that scope (Navidrome does not apply musicFolderId to those
endpoints). Request larger similar batches under a narrow scope.

Keep radio-from-track as two blocks (shuffled top songs, then shuffled
similar-by-artist) so it differs from Instant Mix. Show Alpha badge beside
the setting. Add research/ to .gitignore.
2026-04-10 12:07:32 +03:00
Psychotoxical 49f7fe5f6e feat: add ICY metadata and AzuraCast radio streaming support (#146)
feat: add ICY metadata and AzuraCast radio streaming support
2026-04-10 11:05:59 +02:00
Psychotoxical 28943f1ecb chore(settings): disable UI scale slider pending rework
Interface scaling is temporarily disabled in the Settings UI
(opacity + pointer-events: none) with a note that it will return
in a future update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:04:21 +02:00
Nils Israel 46cefb5712 feat: add ICY metadata and AzuraCast radio streaming support
Agent-Logs-Url: https://github.com/nisrael/psysonic/sessions/88faada5-28bb-446f-b53b-46a0efef387e

Co-authored-by: GitHub Copilot <198982749+copilot@users.noreply.github.com>
Signed-off-by: Nils Israel <nils@sxda.io>
2026-04-10 10:38:59 +02:00
Psychotoxical 74985fe331 fix(audio): fall back to track gain when album gain is missing
In album gain mode, tracks without albumGain tags received no
ReplayGain adjustment at all. Now falls back to trackGain before
defaulting to 0 dB — fixes inconsistent loudness across albums
where only some tracks have albumGain metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:12:06 +02:00
Psychotoxical 4861d99cb1 fix(statistics): library-scoped genre stats, cached fetches, duration i18n
fix(statistics): library-scoped genre stats, cached fetches, duration i18n
2026-04-10 01:11:29 +02:00
Maxim Isaev d2592839b0 i18n(ru): use «Популярное» for most-played nav, home, and page title
«Чаще всего» read awkwardly; «Популярное» matches the play-count sense
without sounding like a grammar exercise.
2026-04-10 02:06:24 +03:00
Maxim Isaev bbeb2f661e merge: integrate origin/main into fix/statistics-i18n-small-fixes 2026-04-10 01:42:14 +03:00
Psychotoxical 5f0fb5dcbd feat(audio): auto-switch to new audio output device at runtime
Adds a background device-watcher that polls the OS default output device
every 3 s via CPAL. When the device changes (Bluetooth headphones connecting,
USB DAC plugging in, etc.) the stream is reopened on the new device, the old
Sink is dropped (it was bound to the now-closed OutputStream), and
audio:device-changed is emitted to the frontend.

Frontend handler (TauriEventBridge): if playing, restarts the current track
from the saved position; if paused, clears the warm-pause flag so the next
resume uses the cold path (audio_play + seek) which creates a new Sink on
the new device.

Fixes #143 (audio through speakers after connecting Bluetooth headphones).
Also covers the intermittently reported single-channel output after long idle,
which can be caused by the OS reconfiguring the audio device in the background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:40:12 +02:00
Maxim Isaev 6f6cb0fd6b fix(statistics): cache Subsonic stat fetches and localize duration units
Add 7-minute TTL caches (same idea as rating prefetch) for overview
album strips, random-song format sample, and paginated library
aggregates; key by server and music folder.

Introduce formatHumanHoursMinutes with common.duration* strings in all
locales for Statistics and playlist duration labels.

Refresh RU/ZH statistics and nav wording; fix zh playtime label.
2026-04-10 01:39:08 +03:00
Psychotoxical 15dc970f53 fix(audio): restore msg binding in DecodeError arm, use SlidersVertical for EQ icon
- Revert _msg → msg in DecodeError match arm; the variable is used in the
  debug_assertions eprintln! block so the underscore prefix caused a compile error
- Replace SlidersHorizontal with SlidersVertical (lucide-react) in PlayerBar,
  LiveSearch, and AdvancedSearch so the EQ/filter icon shows vertical bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:28:12 +02:00
Maxim Isaev fc40d235d0 fix(statistics): scope genre insights to selected music library
getGenres() ignores musicFolderId. Derive genre counts from the same
paginated getAlbumList('newest') scan used for playtime and totals,
using each album's genre and songCount.
2026-04-10 01:18:44 +03:00
Psychotoxical f304589ea1 fix(audio): suppress unused variable warning in DecodeError match arm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:15:55 +02:00
Psychotoxical 22f7de45e8 fix(audio): set User-Agent header, add debug fetch logging
Set psysonic/<version> as User-Agent on the audio reqwest::Client to
prevent reverse proxies (nginx, Caddy, Traefik) from blocking requests
with a 403. Add #[cfg(debug_assertions)] logging in fetch_data to show
the exact URL, status, content-type and server header — useful for
diagnosing proxy-related 403s reported by users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:52:02 +02:00
52 changed files with 2583 additions and 539 deletions
+3
View File
@@ -40,3 +40,6 @@ memory/
# Local scratchpad / notes (not committed)
tmp/
# Third-party clones for local research (not committed)
research/
+51
View File
@@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.34.8] - 2026-04-10
### Added
- **Netease Cloud Music Lyrics** *(opt-in)*: Netease Cloud Music can now be enabled in Settings → General as a last-resort lyrics fallback. It only fires when neither the server nor LRCLIB return results — the existing lyrics chain is completely unaffected. Particularly useful for Asian and international music. Chinese metadata lines (作词/作曲/编曲 etc.) are automatically stripped from the LRC output.
- **Navidrome AudioMuse-AI Integration** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))*: Psysonic now supports [AudioMuse-AI](https://github.com/cucadmuh/audiomuse-ai) if it is active on the Navidrome server and uses it for Random Mix, Similar Artists, and Instant Mix. No configuration required — Psysonic keeps its existing behavior when AudioMuse is unavailable. Also includes an Instant Mix probe, ping identity, and improved UX for AudioMuse-specific actions.
- **ICY metadata & AzuraCast radio** *(contributed by [@sorensiimSalling](https://github.com/sorensiimSalling), PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))*: Internet radio now displays live track metadata from ICY streams. AzuraCast stations are supported with extended now-playing information.
- **Automatic audio device switching**: Psysonic now detects newly connected or changed audio output devices and switches to them automatically — no app restart required.
### Fixed
- **Multi-artist tracks**: Tracks with multiple artists (OpenSubsonic `artists[]` field, e.g. semicolon-separated entries) now display each artist individually. Artists with their own profile page are clickable links; artists without one appear as plain text. Separated by `·`.
- **Gapless + Preload Gate**: The gapless chain and the preload gate now run on separate paths. Previously both could fire simultaneously, causing a brief black flash on track change.
- **Replay Gain — missing album gain**: When no album gain tag is present, Psysonic now correctly falls back to track gain instead of skipping gain correction entirely.
- **Statistics — music library scope**: Genre insights now respect the currently selected music library. Fetch results are cached to avoid redundant server requests. Playback durations are displayed in localized units.
- **Russian locale**: "Most Played" in the sidebar, home page, and page title now uses «Популярное».
### Changed
- **"Reset to defaults" buttons** in Settings → Input are now styled as warning buttons (red border).
- **Lyrics button** removed from the player bar (redundant with the queue panel tab).
- **Icons**: Advanced search now uses the `TextSearch` icon; artist bio button now uses `Highlighter`.
- **Album chip** in the album detail header is now opaque across all themes.
- **Hot Cache and Hi-Res Audio**: Alpha badges removed — both features are production-ready.
- **CPU optimisations**: Next-track buffering and preload settings have been consolidated into a unified control.
### Theme Fixes
- **Middle Earth**: Removed vertical stripe pattern from sidebar; improved queue artist contrast on hover; fixed album detail artist colour, bio text, and "Read more" link readability; "Next Tracks" divider label is now lighter.
- **Toy Tale**: Fixed sidebar section labels (System/Library), queue tab buttons (Lyrics/Queue), inactive artist text, and "Next Tracks" divider label — all were too dark to read.
- **Tetrastack**: Raised all purple and blue palette values (`#a020f0``#c070ff`, `#0060f0``#4090ff`); raised `--text-muted` from `#3a3a6a` to `#7878b8` — affected settings descriptions, artist names in tracklists, and queue labels.
- **Horde & Alliance**: Removed repeating horizontal line pattern from sidebar.
### Contributors
Thank you to everyone who contributed to this release:
- [@cucadmuh](https://github.com/cucadmuh) — AudioMuse-AI Navidrome integration (PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))
- [@sorensiimSalling](https://github.com/sorensiimSalling) — ICY metadata & AzuraCast radio support (PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))
You make Psysonic better. 🙌
---
## [1.34.7] - 2026-04-09
### Added
+4
View File
@@ -8,10 +8,14 @@
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
</p>
</div>
> [!WARNING]
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
---
<div align="center">
+16 -13
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.34.6",
"version": "1.34.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.34.6",
"version": "1.34.8",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
@@ -1942,14 +1942,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/baseline-browser-mapping": {
@@ -2696,10 +2696,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/react": {
"version": "18.3.1",
@@ -2996,9 +2999,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.7",
"version": "1.34.8",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.34.7
pkgver=1.34.8
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+1 -1
View File
@@ -3397,7 +3397,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.34.6"
version = "1.34.7"
dependencies = [
"biquad",
"discord-rich-presence",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.34.7"
version = "1.34.8"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+94 -3
View File
@@ -1149,7 +1149,7 @@ impl Iterator for SizedDecoder {
self.current_frame_offset = 0;
break;
}
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
Err(symphonia::core::errors::Error::DecodeError(ref _msg)) => {
self.consecutive_decode_errors += 1;
// Log sparingly: first drop, then every 10th to avoid spam.
#[cfg(debug_assertions)]
@@ -1684,6 +1684,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
http_client: reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.use_rustls_tls()
.user_agent(format!("psysonic/{}", env!("CARGO_PKG_VERSION")))
.build()
.unwrap_or_default(),
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
@@ -1769,6 +1770,24 @@ async fn fetch_data(
}
let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?;
#[cfg(debug_assertions)]
{
let status = response.status();
let ct = response.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
let server_hdr = response.headers()
.get("server")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
// Strip auth params from URL before logging.
let safe_url = url.split('?').next().unwrap_or(url);
eprintln!(
"[audio] fetch {} → {} | content-type: {} | server: {}",
safe_url, status, ct, server_hdr
);
}
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None); // superseded
@@ -2326,8 +2345,8 @@ fn spawn_progress_task(
let mut samples_played = samples_played;
loop {
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
tokio::time::sleep(Duration::from_millis(100)).await;
// 500 ms tick — frontend interpolates visually at 60 fps via rAF.
tokio::time::sleep(Duration::from_millis(500)).await;
if gen_counter.load(Ordering::SeqCst) != gen {
break;
@@ -2864,3 +2883,75 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
// ─── Device-change watcher ────────────────────────────────────────────────────
//
// Polls the OS default output device every 3 s. When it changes (Bluetooth
// headphones connecting, USB DAC plugging in, etc.) the stream is reopened on
// the new device and `audio:device-changed` is emitted so the frontend can
// restart playback. The old Sink is dropped here — it was bound to the
// now-closed OutputStream and can no longer produce audio on any device.
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let stream_rate = engine.stream_sample_rate.clone();
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
tauri::async_runtime::spawn(async move {
let mut last_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}).await.unwrap_or(None);
loop {
tokio::time::sleep(Duration::from_secs(3)).await;
let current_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}).await.unwrap_or(None);
if current_name == last_name {
continue;
}
last_name = current_name.clone();
// Only act if there is actually a device to open.
let Some(_new_name) = current_name else { continue };
// Debounce: give the OS time to finish configuring the new device.
tokio::time::sleep(Duration::from_millis(500)).await;
let rate = stream_rate.load(Ordering::Relaxed);
let reopen_tx2 = reopen_tx.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
if reopen_tx2.send((rate, false, reply_tx)).is_err() {
return None; // audio thread exited
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None);
let Some(handle) = new_handle else {
eprintln!("[psysonic] device-watcher: stream reopen timed out");
continue;
};
*stream_handle.lock().unwrap() = handle;
// Drop the old Sink — it was bound to the now-closed OutputStream.
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-changed", ()).ok();
}
});
}
+197
View File
@@ -250,6 +250,146 @@ async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
Ok((bytes.to_vec(), content_type))
}
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
/// Returns the response body as a UTF-8 string for parsing on the JS side.
#[tauri::command]
async fn fetch_json_url(url: String) -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(&url)
.header("User-Agent", "psysonic/1.0")
.header("Accept", "application/json")
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let text = resp.text().await.map_err(|e| e.to_string())?;
Ok(text)
}
/// ICY metadata response returned to the frontend.
#[derive(serde::Serialize)]
struct IcyMetadata {
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
stream_title: Option<String>,
/// Value of the `icy-name` response header.
icy_name: Option<String>,
/// Value of the `icy-genre` response header.
icy_genre: Option<String>,
/// Value of the `icy-url` response header.
icy_url: Option<String>,
/// Value of the `icy-description` response header.
icy_description: Option<String>,
}
/// Fetch ICY in-stream metadata from a radio stream URL.
///
/// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes
/// (up to `icy-metaint` audio bytes plus the following metadata block) to
/// extract the `StreamTitle`. The connection is dropped as soon as the
/// first metadata chunk has been parsed, so bandwidth usage is minimal.
#[tauri::command]
async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
use futures_util::StreamExt;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(&url)
.header("Icy-MetaData", "1")
.header("User-Agent", "psysonic/1.0")
.send()
.await
.map_err(|e| e.to_string())?;
// Harvest ICY headers before consuming the body.
let headers = resp.headers();
let icy_name = headers.get("icy-name").and_then(|v| v.to_str().ok()).map(str::to_string);
let icy_genre = headers.get("icy-genre").and_then(|v| v.to_str().ok()).map(str::to_string);
let icy_url = headers.get("icy-url").and_then(|v| v.to_str().ok()).map(str::to_string);
let icy_description = headers.get("icy-description").and_then(|v| v.to_str().ok()).map(str::to_string);
let metaint: Option<usize> = headers
.get("icy-metaint")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
// If the server doesn't advertise a metaint we can still return header info.
let Some(metaint) = metaint else {
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
};
// Cap metaint at 64 KiB to avoid reading unreasonably large audio chunks.
let metaint = metaint.min(65_536);
let needed = metaint + 1; // +1 for the metadata-length byte
let mut buf: Vec<u8> = Vec::with_capacity(needed + 256);
let mut stream = resp.bytes_stream();
while buf.len() < needed {
let Some(chunk) = stream.next().await else { break };
let chunk = chunk.map_err(|e| e.to_string())?;
buf.extend_from_slice(&chunk);
}
if buf.len() < needed {
// Stream ended before we reached the metadata block.
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
}
// The byte immediately after `metaint` audio bytes encodes metadata length:
// actual_bytes = length_byte * 16
let meta_len = buf[metaint] as usize * 16;
if meta_len == 0 {
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
}
// We may need to read a few more chunks to get the full metadata block.
let total_needed = needed + meta_len;
while buf.len() < total_needed {
let Some(chunk) = stream.next().await else { break };
let chunk = chunk.map_err(|e| e.to_string())?;
buf.extend_from_slice(&chunk);
}
let meta_start = needed; // index of first metadata byte
let meta_end = (meta_start + meta_len).min(buf.len());
let meta_bytes = &buf[meta_start..meta_end];
// ICY metadata is Latin-1 encoded; convert to a Rust String lossily.
let meta_str: String = meta_bytes
.iter()
.map(|&b| if b == 0 { '\0' } else { b as char })
.collect::<String>();
// Parse StreamTitle='...' — value ends at the next unescaped single-quote.
let stream_title = meta_str
.split("StreamTitle='")
.nth(1)
.and_then(|s| {
// Find closing quote that is NOT preceded by a backslash.
let mut prev = '\0';
let mut end = s.len();
for (i, c) in s.char_indices() {
if c == '\'' && prev != '\\' {
end = i;
break;
}
prev = c;
}
let title = s[..end].trim().to_string();
if title.is_empty() { None } else { Some(title) }
});
Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description })
}
/// 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.
@@ -715,6 +855,53 @@ async fn download_update(url: String, filename: String, app: tauri::AppHandle) -
}
}
/// Fetches synced lyrics from Netease Cloud Music for a given artist + title.
/// Performs a track search, then fetches the LRC string for the best match.
/// Returns `None` if no match or no lyrics are found.
#[tauri::command]
async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Option<String>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.build()
.map_err(|e| e.to_string())?;
let query = format!("{} {}", artist, title);
let params = [("s", query.as_str()), ("type", "1"), ("limit", "5")];
let search: serde_json::Value = client
.post("https://music.163.com/api/search/get")
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
.header("Referer", "https://music.163.com")
.form(&params)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let song_id = match search["result"]["songs"][0]["id"].as_i64() {
Some(id) => id,
None => return Ok(None),
};
let lyrics: serde_json::Value = client
.get(format!(
"https://music.163.com/api/song/lyric?id={}&lv=1&kv=1&tv=-1",
song_id
))
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
.header("Referer", "https://music.163.com")
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let lrc = lyrics["lrc"]["lyric"].as_str().unwrap_or("").trim().to_string();
Ok(if lrc.is_empty() { None } else { Some(lrc) })
}
/// Reads embedded synced / unsynced lyrics from a local audio file.
///
/// Priority order:
@@ -1386,6 +1573,13 @@ pub fn run() {
}
}
// ── Audio device-change watcher ───────────────────────────────
{
use tauri::Manager;
let engine = app.state::<audio::AudioEngine>();
audio::start_device_watcher(&engine, app.handle().clone());
}
Ok(())
})
.on_window_event(|window, event| {
@@ -1444,6 +1638,8 @@ pub fn run() {
search_radio_browser,
get_top_radio_stations,
fetch_url_bytes,
fetch_json_url,
fetch_icy_metadata,
download_track_offline,
delete_offline_track,
get_offline_cache_size,
@@ -1458,6 +1654,7 @@ pub fn run() {
download_update,
open_folder,
get_embedded_lyrics,
fetch_netease_lyrics,
#[cfg(target_os = "windows")]
taskbar_win::update_taskbar_icon,
])
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.34.7",
"version": "1.34.8",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+31
View File
@@ -413,6 +413,28 @@ function TauriEventBridge() {
return () => { unlisten?.(); };
}, []);
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
// The Rust device-watcher has already reopened the stream on the new device
// and dropped the old Sink, so we just need to restart playback.
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-changed', () => {
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
const pos = currentTime;
const dur = currentTrack.duration || 1;
playTrack(currentTrack);
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
} else {
// Paused: clear warm-pause flag so the next resume uses the cold path
// (audio_play + seek) which creates a new Sink on the new device.
resetAudioPause();
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
@@ -535,6 +557,7 @@ export default function App() {
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const uiScale = useFontStore(s => s.uiScale);
const setUiScale = useFontStore(s => s.setUiScale);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => {
@@ -545,6 +568,14 @@ export default function App() {
document.documentElement.setAttribute('data-font', font);
}, [font]);
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework.
// Reset any stored non-100% value so users aren't stuck at a broken scale.
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx.
useEffect(() => {
if (uiScale !== 1.0) setUiScale(1.0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
document.documentElement.style.zoom = String(uiScale);
}, [uiScale]);
+111
View File
@@ -0,0 +1,111 @@
import { invoke } from '@tauri-apps/api/core';
// ─── AzuraCast API types ──────────────────────────────────────────────────────
export interface AzuraCastSong {
artist: string;
title: string;
album: string;
art?: string;
text?: string; // "Artist - Title" combined
}
export interface AzuraCastNowPlayingTrack {
song: AzuraCastSong;
duration: number; // seconds
elapsed: number; // seconds played so far
remaining: number; // seconds remaining
played_at?: number;
}
export interface AzuraCastListeners {
current: number;
unique?: number;
total?: number;
}
export interface AzuraCastNowPlaying {
now_playing: AzuraCastNowPlayingTrack;
playing_next?: { song: AzuraCastSong } | null;
song_history: Array<{ song: AzuraCastSong; played_at?: number }>;
listeners: AzuraCastListeners;
station?: { name: string; shortcode: string };
}
// ─── Detection helpers ────────────────────────────────────────────────────────
/**
* Try to derive an AzuraCast NowPlaying API URL from a stream URL.
*
* AzuraCast stream URLs follow the pattern:
* https://<host>/listen/<shortcode>/<bitrate>.<ext>
*
* Returns the candidate API URL or `null` if the pattern doesn't match.
*/
export function guessAzuraCastApiUrl(streamUrl: string): string | null {
try {
const u = new URL(streamUrl);
const parts = u.pathname.split('/').filter(Boolean);
// Expect: ['listen', '<shortcode>', '<file>']
if (parts.length >= 2 && parts[0] === 'listen') {
const shortcode = parts[1];
return `${u.origin}/api/nowplaying/${shortcode}`;
}
} catch {
// ignore invalid URLs
}
return null;
}
/**
* Check whether a homepage URL itself looks like an AzuraCast NowPlaying
* API endpoint and return the canonical URL to use.
*
* Accepts:
* - https://<host>/api/nowplaying → all stations, we use as-is
* - https://<host>/api/nowplaying/<shortcode> → single station, use as-is
*/
export function normaliseAzuraCastHomepageUrl(homepageUrl: string): string | null {
try {
const u = new URL(homepageUrl);
if (/^\/api\/nowplaying(\/[^/]+)?$/.test(u.pathname)) {
return homepageUrl;
}
} catch {
// ignore
}
return null;
}
/**
* Fetch AzuraCast NowPlaying data from the given API URL (bypasses CORS via
* the Rust backend). Returns `null` if the request fails or the response
* does not look like a valid AzuraCast payload.
*
* When the API URL points to the `/api/nowplaying` (array) endpoint, the
* first item in the array is returned. Otherwise the single-object form is
* used directly.
*/
export async function fetchAzuraCastNowPlaying(apiUrl: string): Promise<AzuraCastNowPlaying | null> {
try {
const raw: string = await invoke('fetch_json_url', { url: apiUrl });
const parsed = JSON.parse(raw);
// If the response is an array (all-stations endpoint), take the first item.
const obj: unknown = Array.isArray(parsed) ? parsed[0] : parsed;
if (!obj || typeof obj !== 'object') return null;
const np = obj as Record<string, unknown>;
// Minimal validation: must have `now_playing` with a `song` inside.
if (
np.now_playing &&
typeof np.now_playing === 'object' &&
(np.now_playing as Record<string, unknown>).song
) {
return np as unknown as AzuraCastNowPlaying;
}
} catch {
// Network error, JSON parse error, etc.
}
return null;
}
+10
View File
@@ -0,0 +1,10 @@
import { invoke } from '@tauri-apps/api/core';
/** Fetches a synced LRC string from Netease Cloud Music via Rust proxy. Returns null if not found. */
export async function fetchNeteaselyrics(artist: string, title: string): Promise<string | null> {
try {
return await invoke<string | null>('fetch_netease_lyrics', { artist, title });
} catch {
return null;
}
}
+353 -9
View File
@@ -3,6 +3,11 @@ import md5 from 'md5';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
import {
isNavidromeAudiomuseSoftwareEligible,
type InstantMixProbeResult,
type SubsonicServerIdentity,
} from '../utils/subsonicServerIdentity';
// ─── Secure random salt ────────────────────────────────────────
function secureRandomSalt(): string {
@@ -265,8 +270,14 @@ export async function ping(): Promise<boolean> {
}
}
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
/** Test a connection with explicit credentials — does NOT depend on store state. */
export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise<boolean> {
export async function pingWithCredentials(
serverUrl: string,
username: string,
password: string,
): Promise<PingWithCredentialsResult> {
try {
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
const salt = secureRandomSalt();
@@ -277,12 +288,108 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
return data?.status === 'ok';
const ok = data?.status === 'ok';
return {
ok,
type: typeof data?.type === 'string' ? data.type : undefined,
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
openSubsonic: data?.openSubsonic === true,
};
} catch {
return false;
return { ok: false };
}
}
function restBaseFromUrl(serverUrl: string): string {
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
return `${base}/rest`;
}
async function apiWithCredentials<T>(
serverUrl: string,
username: string,
password: string,
endpoint: string,
extra: Record<string, unknown> = {},
timeout = 15000,
): Promise<T> {
const params = { ...getAuthParams(username, password), ...extra };
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
params,
paramsSerializer: { indexes: null },
timeout,
});
const data = resp.data?.['subsonic-response'];
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
return data as T;
}
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
/**
* Probes whether `getSimilarSongs` returns any tracks (Instant Mix / Navidrome agent chain).
* Does not pass `musicFolderId` probes the whole library as seen by the account.
* Note: if `ND_AGENTS` includes Last.fm, a positive result does not prove AudioMuse alone.
*/
export async function probeInstantMixWithCredentials(
serverUrl: string,
username: string,
password: string,
): Promise<InstantMixProbeResult> {
try {
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverUrl,
username,
password,
'getRandomSongs.view',
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
12000,
);
const raw = data.randomSongs?.song;
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
if (songs.length === 0) return 'skipped';
let anyError = false;
for (const song of songs.slice(0, INSTANT_MIX_PROBE_MAX_TRACKS)) {
try {
const simData = await apiWithCredentials<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverUrl,
username,
password,
'getSimilarSongs.view',
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
12000,
);
const sRaw = simData.similarSongs?.song;
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
if (list.some(s => s.id !== song.id)) return 'ok';
} catch {
anyError = true;
}
}
return anyError ? 'error' : 'empty';
} catch {
return 'error';
}
}
/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */
export function scheduleInstantMixProbeForServer(
serverId: string,
serverUrl: string,
username: string,
password: string,
identity: SubsonicServerIdentity,
): void {
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return;
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
useAuthStore.getState().setInstantMixProbe(serverId, result),
);
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
@@ -309,6 +416,68 @@ export async function getAlbumList(
return data.albumList2?.album ?? [];
}
/**
* Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs,
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
*/
let scopedLibraryAlbumIdCache: {
serverId: string;
folderId: string;
filterVersion: number;
ids: Set<string>;
} | null = null;
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId];
if (folder === undefined || folder === 'all') {
scopedLibraryAlbumIdCache = null;
return null;
}
const hit = scopedLibraryAlbumIdCache;
if (
hit &&
hit.serverId === activeServerId &&
hit.folderId === folder &&
hit.filterVersion === musicLibraryFilterVersion
) {
return hit.ids;
}
const ids = new Set<string>();
const pageSize = 500;
let offset = 0;
for (;;) {
const albums = await getAlbumList('alphabeticalByName', pageSize, offset);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
}
scopedLibraryAlbumIdCache = {
serverId: activeServerId,
folderId: folder,
filterVersion: musicLibraryFilterVersion,
ids,
};
return ids;
}
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
const allowed = await albumIdsInActiveLibraryScope();
if (!allowed || allowed.size === 0) return songs;
return songs.filter(s => s.albumId && allowed.has(s.albumId));
}
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
function similarSongsRequestCount(desired: number): number {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
if (f === undefined || f === 'all') return desired;
return Math.min(300, Math.max(desired, desired * 4));
}
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
if (genre) params.genre = genre;
@@ -425,6 +594,157 @@ export async function prefetchAlbumUserRatings(
return out;
}
/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */
export interface StatisticsLibraryAggregates {
playtimeSec: number;
albumsCounted: number;
songsCounted: number;
capped: boolean;
genres: SubsonicGenre[];
}
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
function statisticsPageCacheKey(prefix: string): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
const folderPart = folder === 'all' ? 'all' : folder;
return `${prefix}:${activeServerId}:${folderPart}`;
}
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
/**
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
* 7 minutes (same `RATING_CACHE_TTL` as album/artist rating prefetch).
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
*/
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
const key = statisticsPageCacheKey('statsAgg');
if (key) {
const hit = statisticsAggregatesCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const maxPages = 10;
let capped = false;
let offset = 0;
let nextPage = getAlbumList('newest', pageSize, 0);
for (let page = 0; page < maxPages; page++) {
try {
const albums = await nextPage;
for (const a of albums) {
playtimeSec += a.duration ?? 0;
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const label = (a.genre?.trim()) ? a.genre.trim() : '';
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
if (albums.length < pageSize) break;
if (page === maxPages - 1) {
capped = true;
break;
}
offset += pageSize;
nextPage = getAlbumList('newest', pageSize, offset);
} catch {
break;
}
}
const genres: SubsonicGenre[] = [...genreAgg.entries()]
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
.sort((a, b) => b.songCount - a.songCount);
const result: StatisticsLibraryAggregates = {
playtimeSec,
albumsCounted,
songsCounted,
capped,
genres,
};
if (key) {
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
/** Recent / frequent / highest album strips + artist count for Statistics. */
export interface StatisticsOverviewData {
recent: SubsonicAlbum[];
frequent: SubsonicAlbum[];
highest: SubsonicAlbum[];
artistCount: number;
}
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
const key = statisticsPageCacheKey('statsOverview');
if (key) {
const hit = statisticsOverviewCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const [recent, frequent, highest, artists] = await Promise.all([
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
getArtists().catch(() => [] as SubsonicArtist[]),
]);
const result: StatisticsOverviewData = {
recent,
frequent,
highest,
artistCount: artists.length,
};
if (key) {
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
/** Format (suffix) histogram from a random sample for Statistics. */
export interface StatisticsFormatSample {
rows: { format: string; count: number }[];
sampleSize: number;
}
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
const key = statisticsPageCacheKey('statsFormat');
if (key) {
const hit = statisticsFormatCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const rows = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
if (key) {
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
...libraryFilterParams(),
@@ -439,15 +759,21 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a
return { artist, albums: album ?? [] };
}
export async function getArtistInfo(id: string): Promise<SubsonicArtistInfo> {
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count: 5 });
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
const count = options?.similarArtistCount ?? 5;
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
return data.artistInfo2 ?? {};
}
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
try {
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: 5 });
return data.topSongs?.song ?? [];
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
const topCount = scoped ? 20 : 5;
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
const raw = data.topSongs?.song ?? [];
const filtered = await filterSongsToActiveLibrary(raw);
return filtered.slice(0, 5);
} catch {
return [];
}
@@ -455,8 +781,26 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
try {
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count });
return data.similarSongs2?.song ?? [];
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
const raw = data.similarSongs2?.song ?? [];
const filtered = await filterSongsToActiveLibrary(raw);
return filtered.slice(0, count);
} catch {
return [];
}
}
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
const raw = data.similarSongs?.song;
if (!raw) return [];
const list = Array.isArray(raw) ? raw : [raw];
const filtered = await filterSongsToActiveLibrary(list);
return filtered.slice(0, count);
} catch {
return [];
}
+3 -3
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter } from 'lucide-react';
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
@@ -243,7 +243,7 @@ export default function AlbumHeader({
aria-label={t('albumDetail.artistBio')}
data-tooltip={t('albumDetail.artistBio')}
>
<ExternalLink size={16} />
<Highlighter size={16} />
</button>
{downloadProgress !== null ? (
@@ -313,7 +313,7 @@ export default function AlbumHeader({
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
<Highlighter size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
+17 -8
View File
@@ -227,18 +227,27 @@ export default function AlbumTrackList({
<span className="track-title">{song.title}</span>
</div>
);
case 'artist':
case 'artist': {
const artistRefs = song.artists && song.artists.length > 0
? song.artists
: [{ id: song.artistId, name: song.artist }];
return (
<div key="artist" className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
>
{song.artist}
</span>
{artistRefs.map((a, i) => (
<React.Fragment key={a.id ?? a.name ?? i}>
{i > 0 && <span className="track-artist-sep">&nbsp;·&nbsp;</span>}
<span
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
style={{ cursor: a.id ? 'pointer' : 'default' }}
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
>
{a.name ?? song.artist}
</span>
</React.Fragment>
))}
</div>
);
}
case 'favorite':
return (
<div key="favorite" className="track-star-cell">
+68 -8
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles } from 'lucide-react';
import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { useShallow } from 'zustand/react/shallow';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -14,6 +15,7 @@ import { join } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
function sanitizeFilename(name: string): string {
return name
@@ -175,8 +177,26 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
useShallow(s => ({
contextMenu: s.contextMenu,
closeContextMenu: s.closeContextMenu,
playTrack: s.playTrack,
enqueue: s.enqueue,
queue: s.queue,
currentTrack: s.currentTrack,
removeTrack: s.removeTrack,
lastfmLovedCache: s.lastfmLovedCache,
setLastfmLovedForSong: s.setLastfmLovedForSong,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
openSongInfo: s.openSongInfo,
userRatingOverrides: s.userRatingOverrides,
setUserRatingOverride: s.setUserRatingOverride,
}))
);
const auth = useAuthStore();
const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
@@ -235,12 +255,15 @@ export default function ContextMenu() {
// same "Top 5" in the same order every time.
try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
const radioTracks = shuffleArray(
[...top, ...similar]
.map(songToTrack)
.filter(t => t.id !== seedTrack.id)
.map(t => ({ ...t, radioAdded: true as const }))
// Keep artist top songs and similar-by-artist in two blocks (each shuffled), not one blended pile —
// otherwise this feels the same as Instant Mix (track-based similar only).
const topTracks = shuffleArray(
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
);
const similarTracks = shuffleArray(
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
);
const radioTracks = [...topTracks, ...similarTracks];
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
} catch (e) {
console.error('Failed to load radio queue', e);
@@ -306,6 +329,33 @@ export default function ContextMenu() {
}
};
const startInstantMix = async (song: Track) => {
const state = usePlayerStore.getState();
if (state.currentTrack?.id === song.id) {
if (!state.isPlaying) state.resume();
} else {
playTrack(song, [song]);
}
const serverId = useAuthStore.getState().activeServerId;
try {
const similar = await getSimilarSongs(song.id, 50);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const shuffled = shuffleArray(
similar
.filter(s => s.id !== song.id)
.map(s => ({ ...songToTrack(s), radioAdded: true as const }))
);
if (shuffled.length > 0) {
const aid = song.artistId?.trim() || undefined;
usePlayerStore.getState().enqueueRadio(shuffled, aid);
}
} catch (e) {
console.error('Instant mix failed', e);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
showToast(t('contextMenu.instantMixFailed'), 5000, 'error');
}
};
const downloadAlbum = async (albumName: string, albumId: string) => {
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
@@ -389,6 +439,11 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
{audiomuseNavidromeEnabled && (
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
<Sparkles size={14} /> {t('contextMenu.instantMix')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
@@ -539,6 +594,11 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
{audiomuseNavidromeEnabled && (
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
<Sparkles size={14} /> {t('contextMenu.instantMix')}
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<StarRating
+27 -11
View File
@@ -192,34 +192,50 @@ const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
);
});
// ─── Full-width seekbar (isolated — re-renders every tick) ───────────────────
// ─── Full-width seekbar — imperative DOM updates, zero React re-renders on tick
const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const currentTime = usePlayerStore(s => s.currentTime);
const seek = usePlayerStore(s => s.seek);
const timeRef = useRef<HTMLSpanElement>(null);
const playedRef = useRef<HTMLDivElement>(null);
const bufRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const s = usePlayerStore.getState();
const pct = s.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(s.progress);
return usePlayerStore.subscribe(state => {
const p = state.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
if (playedRef.current) playedRef.current.style.width = `${p}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(state.progress);
});
}, []);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
[seek]
);
const pct = progress * 100;
const buf = Math.max(pct, buffered * 100);
return (
<div className="fs-seekbar-wrap">
<div className="fs-seekbar-times">
<span>{formatTime(currentTime)}</span>
<span ref={timeRef} />
<span>{formatTime(duration)}</span>
</div>
<div className="fs-seekbar">
<div className="fs-seekbar-bg" />
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
<div className="fs-seekbar-buf" ref={bufRef} />
<div className="fs-seekbar-played" ref={playedRef} />
<input
ref={inputRef}
type="range" min={0} max={1} step={0.001}
value={progress}
defaultValue={0}
onChange={handleSeek}
aria-label="seek"
/>
-1
View File
@@ -40,7 +40,6 @@ function HeroBg({ url }: { url: string }) {
style={{
backgroundImage: `url(${layer.url})`,
opacity: layer.visible ? 1 : 0,
filter: layer.visible ? 'blur(0px)' : 'blur(18px)',
}}
aria-hidden="true"
/>
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react';
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -126,7 +126,7 @@ export default function LiveSearch() {
data-tooltip-pos="bottom"
aria-label={t('search.advanced')}
>
<SlidersHorizontal size={14} />
<TextSearch size={14} />
</button>
</div>
+3 -1
View File
@@ -57,7 +57,9 @@ export default function LyricsPane({ currentTrack }: Props) {
? t('player.lyricsSourceServer')
: source === 'lrclib'
? t('player.lyricsSourceLrclib')
: null;
: source === 'netease'
? t('player.lyricsSourceNetease')
: null;
return (
<div className="lyrics-pane">
+87 -23
View File
@@ -1,10 +1,11 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
import CachedImage from './CachedImage';
@@ -16,6 +17,7 @@ import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText';
import LastfmIcon from './LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -24,6 +26,21 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Renders the playback clock without ever causing PlayerBar to re-render.
// Updates the DOM directly via an imperative store subscription.
const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (spanRef.current) {
spanRef.current.textContent = formatTime(usePlayerStore.getState().currentTime);
}
return usePlayerStore.subscribe(state => {
if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime);
});
}, []);
return <span className={className} ref={spanRef} />;
});
export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -31,19 +48,44 @@ export default function PlayerBar() {
const [showVolPct, setShowVolPct] = useState(false);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
const {
currentTrack, currentRadio, isPlaying, currentTime, volume,
currentTrack, currentRadio, isPlaying, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
starredOverrides, setStarredOverride,
userRatingOverrides, setUserRatingOverride,
} = usePlayerStore();
} = usePlayerStore(useShallow(s => ({
currentTrack: s.currentTrack,
currentRadio: s.currentRadio,
isPlaying: s.isPlaying,
volume: s.volume,
togglePlay: s.togglePlay,
next: s.next,
previous: s.previous,
setVolume: s.setVolume,
stop: s.stop,
toggleRepeat: s.toggleRepeat,
repeatMode: s.repeatMode,
toggleFullscreen: s.toggleFullscreen,
lastfmLoved: s.lastfmLoved,
toggleLastfmLove: s.toggleLastfmLove,
isQueueVisible: s.isQueueVisible,
toggleQueue: s.toggleQueue,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
userRatingOverrides: s.userRatingOverrides,
setUserRatingOverride: s.setUserRatingOverride,
})));
const { lastfmSessionKey } = useAuthStore();
const isRadio = !!currentRadio;
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
const radioMeta = useRadioMetadata(currentRadio ?? null);
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
@@ -129,13 +171,23 @@ export default function PlayerBar() {
</div>
<div className="player-track-meta">
<MarqueeText
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
text={isRadio
? (radioMeta.currentTitle
? (radioMeta.currentArtist
? `${radioMeta.currentArtist}${radioMeta.currentTitle}`
: radioMeta.currentTitle)
: (currentRadio?.name ?? '—'))
: (currentTrack?.title ?? t('player.noTitle'))}
className="player-track-name"
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
/>
<MarqueeText
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
text={isRadio
? (radioMeta.currentTitle && currentRadio?.name
? currentRadio.name
: t('radio.liveStream'))
: (currentTrack?.artist ?? '—')}
className="player-track-artist"
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
@@ -148,6 +200,11 @@ export default function PlayerBar() {
ariaLabel={t('albumDetail.ratingLabel')}
/>
)}
{isRadio && radioMeta.listeners != null && (
<span className="player-radio-listeners">
{t('radio.listenerCount', { count: radioMeta.listeners })}
</span>
)}
</div>
{currentTrack && !isRadio && (
<button
@@ -207,15 +264,32 @@ export default function PlayerBar() {
<div className="player-waveform-section">
{isRadio ? (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
<>
<span className="player-time">{formatTime(radioMeta.elapsed)}</span>
<div className="player-waveform-wrap">
<div className="radio-progress-bar">
<div
className="radio-progress-fill"
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
/>
</div>
</div>
<span className="player-time">{formatTime(radioMeta.duration)}</span>
</>
) : (
<>
<PlaybackTime className="player-time" />
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
</>
)}
</>
) : (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<PlaybackTime className="player-time" />
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
@@ -224,16 +298,6 @@ export default function PlayerBar() {
)}
</div>
{/* Lyrics Button */}
<button
className={`player-btn player-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={15} />
</button>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
@@ -241,7 +305,7 @@ export default function PlayerBar() {
aria-label="Equalizer"
data-tooltip="Equalizer"
>
<SlidersHorizontal size={15} />
<SlidersVertical size={15} />
</button>
{/* Volume */}
+4 -1
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { getSong, SubsonicSong } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
@@ -33,7 +34,9 @@ function Divider() {
export default function SongInfoModal() {
const { t } = useTranslation();
const { songInfoModal, closeSongInfo } = usePlayerStore();
const { songInfoModal, closeSongInfo } = usePlayerStore(
useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo }))
);
const [song, setSong] = useState<SubsonicSong | null>(null);
const [loading, setLoading] = useState(false);
+49 -23
View File
@@ -784,6 +784,15 @@ export function SeekbarPreview({
}
// ── main component ────────────────────────────────────────────────────────────
//
// Architecture:
// Static styles (waveform, bar, …): drawn directly in the Zustand subscription
// callback — no React re-renders, no rAF loop. 2 draws/s at the 500 ms
// Rust interval. shadowBlur + 500 canvas bars on a software-rendered
// WebKitGTK context is too expensive for a continuous 60 fps loop.
// Animated styles (pulsewave, particletrail, …): rAF loop at 60 fps, reads
// refs that the subscription keeps up-to-date.
// Drag: draws synchronously in seekToFraction for 1:1 responsiveness.
interface Props {
trackId: string | undefined;
@@ -792,35 +801,48 @@ interface Props {
export default function WaveformSeek({ trackId }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(0);
const bufferedRef = useRef(0);
const progressRef = useRef(usePlayerStore.getState().progress);
const bufferedRef = useRef(usePlayerStore.getState().buffered);
const isDragging = useRef(false);
const animStateRef = useRef<AnimState>(makeAnimState());
const [hoverPct, setHoverPct] = useState<number | null>(null);
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
progressRef.current = progress;
bufferedRef.current = buffered;
// Ref so the subscription callback (closed over at mount) can read the
// current style without stale-closure issues.
const styleRef = useRef(seekbarStyle);
styleRef.current = seekbarStyle;
useEffect(() => {
heightsRef.current = trackId ? makeHeights(trackId) : null;
}, [trackId]);
// Static styles: redraw on progress / buffered / track changes
// Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs.
useEffect(() => {
return usePlayerStore.subscribe((state, prev) => {
if (state.progress === prev.progress && state.buffered === prev.buffered) return;
progressRef.current = state.progress;
bufferedRef.current = state.buffered;
if (!ANIMATED_STYLES.has(styleRef.current)) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered);
}
});
}, []);
// Initial draw for static styles when style or track changes.
useEffect(() => {
if (ANIMATED_STYLES.has(seekbarStyle)) return;
if (canvasRef.current) {
drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered);
}
}, [progress, buffered, trackId, seekbarStyle]);
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
}, [seekbarStyle, trackId]);
// Animated styles: rAF loop
// rAF loop — animated styles only.
useEffect(() => {
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
const canvas = canvasRef.current;
@@ -829,21 +851,14 @@ export default function WaveformSeek({ trackId }: Props) {
let rafId: number;
const tick = () => {
animStateRef.current.time += 0.016;
drawSeekbar(
canvas,
seekbarStyle,
heightsRef.current,
progressRef.current,
bufferedRef.current,
animStateRef.current,
);
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [seekbarStyle]);
// Resize observer
// Resize observer.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -859,12 +874,23 @@ export default function WaveformSeek({ trackId }: Props) {
const seekRef = useRef(seek);
seekRef.current = seek;
// Seek to a 01 fraction: draw immediately for 1:1 responsiveness, then
// let the store + Rust catch up asynchronously.
const seekToFraction = (fraction: number) => {
progressRef.current = fraction;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, fraction, bufferedRef.current);
}
seekRef.current(fraction);
};
useEffect(() => {
const seekFromX = (clientX: number) => {
const canvas = canvasRef.current;
if (!canvas || !trackIdRef.current) return;
const rect = canvas.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
seekToFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
const onUp = () => { isDragging.current = false; };
@@ -892,7 +918,7 @@ export default function WaveformSeek({ trackId }: Props) {
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
seekToFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseMove={e => {
if (!trackId) return;
+15 -3
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAuthStore } from '../store/authStore';
import { pingWithCredentials } from '../api/subsonic';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
@@ -37,8 +37,20 @@ export function useConnectionStatus() {
return;
}
const ok = await pingWithCredentials(server.url, server.username, server.password);
setStatus(ok ? 'connected' : 'disconnected');
const ping = await pingWithCredentials(server.url, server.username, server.password);
if (ping.ok) {
const sid = useAuthStore.getState().activeServerId;
if (sid) {
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
useAuthStore.getState().setSubsonicServerIdentity(sid, identity);
scheduleInstantMixProbeForServer(sid, server.url, server.username, server.password, identity);
}
}
setStatus(ping.ok ? 'connected' : 'disconnected');
}, []);
const retry = useCallback(async () => {
+25 -3
View File
@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
import { fetchNeteaselyrics } from '../api/netease';
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore';
import type { Track } from '../store/playerStore';
export type LyricsSource = 'server' | 'lrclib' | 'embedded';
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded';
export interface CachedLyrics {
syncedLines: LrcLine[] | null;
@@ -46,7 +47,8 @@ export interface UseLyricsResult {
export function useLyrics(currentTrack: Track | null): UseLyricsResult {
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
const enableNeteaselyrics = useAuthStore(s => s.enableNeteaselyrics);
const [loading, setLoading] = useState(!cached && !!currentTrack);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
@@ -142,6 +144,21 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
}
};
const NETEASE_META = /^(作词|作曲|编曲|制作人|出版|发行|MV导演|录音|混音|监制)/;
const fetchNetease = async (): Promise<boolean> => {
try {
const lrc = await fetchNeteaselyrics(currentTrack.artist ?? '', currentTrack.title);
if (!lrc) return false;
const lines = parseLrc(lrc).filter(l => !NETEASE_META.test(l.text));
const synced = lines.length > 0 ? lines : null;
if (!synced) return false;
store({ syncedLines: synced, plainLyrics: null, source: 'netease', notFound: false });
return true;
} catch {
return false;
}
};
(async () => {
// Embedded lyrics from local file always win (most accurate SYLT data).
if (cancelled) return;
@@ -155,11 +172,16 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (await first()) return;
if (cancelled) return;
if (await second()) return;
// Netease as last fallback — only when explicitly enabled
if (enableNeteaselyrics) {
if (cancelled) return;
if (await fetchNetease()) return;
}
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
})();
return () => { cancelled = true; };
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [currentTrack?.id, enableNeteaselyrics]); // eslint-disable-line react-hooks/exhaustive-deps
return { syncedLines, plainLyrics, source, loading, notFound };
}
+208
View File
@@ -0,0 +1,208 @@
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { InternetRadioStation } from '../api/subsonic';
import {
guessAzuraCastApiUrl,
normaliseAzuraCastHomepageUrl,
fetchAzuraCastNowPlaying,
type AzuraCastNowPlaying,
type AzuraCastSong,
} from '../api/azuracast';
// ─── Public types ─────────────────────────────────────────────────────────────
export type RadioMetadataSource = 'azuracast' | 'icy' | 'none';
export interface RadioHistoryItem {
song: AzuraCastSong;
playedAt?: number; // unix timestamp
}
export interface RadioMetadata {
/** Metadata source that is currently active. */
source: RadioMetadataSource;
/** Station name (from ICY icy-name or AzuraCast station.name). */
stationName?: string;
/** Current track title (combined or individual fields). */
currentTitle?: string;
currentArtist?: string;
currentAlbum?: string;
currentArt?: string;
/** AzuraCast-only: seconds elapsed in current track. */
elapsed?: number;
/** AzuraCast-only: total duration of current track in seconds. */
duration?: number;
/** AzuraCast-only: number of current listeners. */
listeners?: number;
/** AzuraCast-only: last N played tracks. */
history: RadioHistoryItem[];
/** AzuraCast-only: next track queued. */
nextSong?: AzuraCastSong;
}
// ─── ICY metadata interface (matches Rust IcyMetadata struct) ─────────────────
interface IcyMetadataResult {
stream_title?: string;
icy_name?: string;
icy_genre?: string;
icy_url?: string;
icy_description?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseIcyStreamTitle(streamTitle: string): { artist?: string; title: string } {
const sep = streamTitle.indexOf(' - ');
if (sep !== -1) {
return { artist: streamTitle.slice(0, sep).trim(), title: streamTitle.slice(sep + 3).trim() };
}
return { title: streamTitle };
}
function nowPlayingToMetadata(np: AzuraCastNowPlaying): RadioMetadata {
const nowPlaying = np.now_playing;
const song = nowPlaying?.song;
return {
source: 'azuracast',
stationName: np.station?.name,
currentTitle: song?.title,
currentArtist: song?.artist,
currentAlbum: song?.album,
currentArt: song?.art,
elapsed: nowPlaying?.elapsed,
duration: nowPlaying?.duration,
listeners: np.listeners?.current,
history: (np.song_history ?? []).slice(0, 5).map(h => ({
song: h.song,
playedAt: h.played_at,
})),
nextSong: np.playing_next?.song ?? undefined,
};
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
const AZURACAST_POLL_MS = 15_000;
const ICY_POLL_MS = 30_000;
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
const elapsedRef = useRef<number | null>(null);
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stationRef = useRef<InternetRadioStation | null>(null);
// Store resolved AzuraCast API URL for the current station (or null).
const azuraCastUrlRef = useRef<string | null>(null);
// Stop the elapsed ticker.
function stopElapsedTick() {
if (elapsedIntervalRef.current) {
clearInterval(elapsedIntervalRef.current);
elapsedIntervalRef.current = null;
}
elapsedRef.current = null;
}
// Start a 1-second elapsed ticker that advances the stored elapsed value and
// updates the metadata state so the progress bar moves smoothly between polls.
function startElapsedTick(initial: number) {
stopElapsedTick();
elapsedRef.current = initial;
elapsedIntervalRef.current = setInterval(() => {
if (elapsedRef.current === null) return;
elapsedRef.current += 1;
setMetadata(prev =>
prev.source === 'azuracast'
? { ...prev, elapsed: elapsedRef.current! }
: prev
);
}, 1000);
}
useEffect(() => {
if (!station) {
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
return;
}
stationRef.current = station;
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
let cancelled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
// Determine which AzuraCast API URL to try, in priority order:
// 1. Homepage URL if it matches the /api/nowplaying[/shortcode] pattern
// 2. Guessed URL from stream URL path (/listen/<shortcode>/…)
const candidateApiUrl =
(station.homepageUrl ? normaliseAzuraCastHomepageUrl(station.homepageUrl) : null) ??
guessAzuraCastApiUrl(station.streamUrl);
async function pollAzuraCast(apiUrl: string) {
if (cancelled) return;
const np = await fetchAzuraCastNowPlaying(apiUrl);
if (cancelled) return;
if (np) {
const m = nowPlayingToMetadata(np);
setMetadata(m);
startElapsedTick(m.elapsed ?? 0);
pollTimer = setTimeout(() => pollAzuraCast(apiUrl), AZURACAST_POLL_MS);
} else {
// AzuraCast check failed — fall back to ICY
azuraCastUrlRef.current = null;
pollIcy();
}
}
async function pollIcy() {
if (cancelled) return;
const currentStation = stationRef.current;
if (!currentStation) return;
try {
const result: IcyMetadataResult = await invoke('fetch_icy_metadata', { url: currentStation.streamUrl });
if (cancelled) return;
if (result.stream_title || result.icy_name) {
const parsed = result.stream_title ? parseIcyStreamTitle(result.stream_title) : null;
setMetadata({
source: 'icy',
stationName: result.icy_name,
currentTitle: parsed?.title,
currentArtist: parsed?.artist,
history: [],
});
}
} catch {
// ICY metadata not available — leave empty metadata
}
if (!cancelled) {
pollTimer = setTimeout(pollIcy, ICY_POLL_MS);
}
}
// Kick off detection and polling.
if (candidateApiUrl) {
// Try AzuraCast first; fall back to ICY inside pollAzuraCast if it fails.
azuraCastUrlRef.current = candidateApiUrl;
pollAzuraCast(candidateApiUrl);
} else {
pollIcy();
}
return () => {
cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
stopElapsedTick();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [station?.id, station?.streamUrl, station?.homepageUrl]);
return metadata;
}
+19
View File
@@ -94,6 +94,8 @@ export const deTranslation = {
addToQueue: 'Zur Warteschlange hinzufügen',
enqueueAlbum: 'Ganzes Album einreihen',
startRadio: 'Radio starten',
instantMix: 'Instant Mix',
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
lfmLove: 'Auf Last.fm liken',
lfmUnlove: 'Last.fm-Like entfernen',
favorite: 'Favorisieren',
@@ -361,6 +363,8 @@ export const deTranslation = {
updaterAurHint: 'Update über AUR installieren:',
updaterErrorMsg: 'Download fehlgeschlagen',
updaterRetryBtn: 'Erneut versuchen',
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
durationMinutesOnly: '{{minutes}} Min.',
updaterOpenGitHub: 'Auf GitHub öffnen',
},
settings: {
@@ -394,6 +398,11 @@ export const deTranslation = {
testBtn: 'Verbindung testen',
testingBtn: 'Teste…',
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Aktivieren, wenn dieser Server das <pluginLink>AudioMuse-AI-Navidrome-Plugin</pluginLink> nutzt. Schaltet Instant Mix pro Titel frei und nutzt ähnliche Künstler vom Server statt Last.fm auf Künstlerseiten.',
audiomuseIssueHint:
'Instant Mix ist kürzlich fehlgeschlagen — Navidrome-Plugin und AudioMuse-API prüfen. Ohne Server-Treffer werden ähnliche Künstler über Last.fm geladen.',
connected: 'Verbunden',
failed: 'Fehlgeschlagen',
eqTitle: 'Equalizer',
@@ -477,6 +486,8 @@ export const deTranslation = {
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
lyricsServerFirst: 'Server-Lyrics bevorzugen',
lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.',
enableNeteaselyrics: 'Netease Cloud Music Lyrics',
enableNeteaselyricsDesc: 'Netease Cloud Music als letzte Lyrics-Quelle verwenden, wenn Server und LRCLIB nichts liefern. Besonders gut für asiatische und internationale Musik.',
downloadsTitle: 'ZIP-Export & Archivierung',
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
downloadsDefault: 'Standard-Downloads-Ordner',
@@ -569,6 +580,9 @@ export const deTranslation = {
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
preloadMode: 'Nächsten Track vorpuffern',
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
nextTrackBufferingTitle: 'Nächster Track Pufferung',
preloadHotCacheMutualExclusive: 'Preload und Hot Cache erfüllen denselben Zweck nur eine Methode kann gleichzeitig aktiv sein. Das Aktivieren einer deaktiviert die andere automatisch.',
preloadOff: 'Aus',
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
preloadCustom: 'Benutzerdefiniert',
@@ -805,6 +819,7 @@ export const deTranslation = {
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
lyricsSourceServer: 'Quelle: Server',
lyricsSourceLrclib: 'Quelle: LRCLIB',
lyricsSourceNetease: 'Quelle: Netease',
},
songInfo: {
title: 'Song-Infos',
@@ -918,6 +933,10 @@ export const deTranslation = {
favorite: 'Zu Favoriten hinzufügen',
unfavorite: 'Aus Favoriten entfernen',
noFavorites: 'Keine Lieblingssender.',
listenerCount_one: '{{count}} Hörer',
listenerCount_other: '{{count}} Hörer',
recentlyPlayed: 'Zuletzt gespielt',
upNext: 'Als Nächstes',
},
folderBrowser: {
empty: 'Leerer Ordner',
+19
View File
@@ -95,6 +95,8 @@ export const enTranslation = {
addToQueue: 'Add to Queue',
enqueueAlbum: 'Enqueue Album',
startRadio: 'Start Radio',
instantMix: 'Instant Mix',
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
lfmLove: 'Love on Last.fm',
lfmUnlove: 'Unlove on Last.fm',
favorite: 'Favorite',
@@ -362,6 +364,8 @@ export const enTranslation = {
updaterAurHint: 'Install the update via AUR:',
updaterErrorMsg: 'Download failed',
updaterRetryBtn: 'Retry',
durationHoursMinutes: '{{hours}}h {{minutes}}m',
durationMinutesOnly: '{{minutes}}m',
updaterOpenGitHub: 'Open on GitHub',
},
settings: {
@@ -395,6 +399,11 @@ export const enTranslation = {
testBtn: 'Test Connection',
testingBtn: 'Testing…',
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Turn on if this server has the <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.',
audiomuseIssueHint:
'Instant Mix failed recently — check the Navidrome plugin and AudioMuse API. Similar artists fall back to Last.fm when the server returns none.',
connected: 'Connected',
failed: 'Failed',
eqTitle: 'Equalizer',
@@ -478,6 +487,8 @@ export const enTranslation = {
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
lyricsServerFirst: 'Prefer server lyrics',
lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.',
enableNeteaselyrics: 'Netease Cloud Music lyrics',
enableNeteaselyricsDesc: 'Use Netease Cloud Music as a last-resort lyrics source when server and LRCLIB both return nothing. Best coverage for Asian and international music.',
downloadsTitle: 'ZIP Export & Archiving',
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
downloadsDefault: 'Default Downloads Folder',
@@ -570,6 +581,9 @@ export const enTranslation = {
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
preloadMode: 'Preload Next Track',
preloadModeDesc: 'When to start buffering the next track in the queue',
nextTrackBufferingTitle: 'Next Track Buffering',
preloadHotCacheMutualExclusive: 'Preload and Hot Cache serve the same purpose — only one can be active at a time. Enabling one will automatically disable the other.',
preloadOff: 'Off',
preloadBalanced: 'Balanced (30 s before end)',
preloadEarly: 'Early (after 5 s of playback)',
preloadCustom: 'Custom',
@@ -806,6 +820,7 @@ export const enTranslation = {
lyricsNotFound: 'No lyrics found for this track',
lyricsSourceServer: 'Source: Server',
lyricsSourceLrclib: 'Source: LRCLIB',
lyricsSourceNetease: 'Source: Netease',
},
songInfo: {
title: 'Song Info',
@@ -919,6 +934,10 @@ export const enTranslation = {
favorite: 'Add to favorites',
unfavorite: 'Remove from favorites',
noFavorites: 'No favorite stations.',
listenerCount_one: '{{count}} listener',
listenerCount_other: '{{count}} listeners',
recentlyPlayed: 'Recently Played',
upNext: 'Up Next',
},
folderBrowser: {
empty: 'Empty folder',
+19
View File
@@ -94,6 +94,8 @@ export const frTranslation = {
addToQueue: 'Ajouter à la file',
enqueueAlbum: 'Mettre l\'album en file',
startRadio: 'Démarrer la radio',
instantMix: 'Mix instantané',
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
lfmLove: 'Aimer sur Last.fm',
lfmUnlove: 'Ne plus aimer sur Last.fm',
favorite: 'Favori',
@@ -361,6 +363,8 @@ export const frTranslation = {
updaterAurHint: 'Installer la mise à jour via AUR :',
updaterErrorMsg: 'Échec du téléchargement',
updaterRetryBtn: 'Réessayer',
durationHoursMinutes: '{{hours}} h {{minutes}} min',
durationMinutesOnly: '{{minutes}} min',
updaterOpenGitHub: 'Ouvrir sur GitHub',
},
settings: {
@@ -394,6 +398,11 @@ export const frTranslation = {
testBtn: 'Tester la connexion',
testingBtn: 'Test en cours…',
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Activez si ce serveur utilise le <pluginLink>plugin Navidrome AudioMuse-AI</pluginLink>. Active le mix instantané depuis un morceau et affiche les artistes similaires côté serveur au lieu de Last.fm sur les pages artiste.',
audiomuseIssueHint:
'Le mix instantané a échoué récemment — vérifiez le plugin Navidrome et lAPI AudioMuse. Les artistes similaires utilisent Last.fm si le serveur ne renvoie rien.',
connected: 'Connecté',
failed: 'Échec',
eqTitle: 'Égaliseur',
@@ -475,6 +484,8 @@ export const frTranslation = {
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
lyricsServerFirst: 'Préférer les paroles du serveur',
lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.',
enableNeteaselyrics: 'Paroles Netease Cloud Music',
enableNeteaselyricsDesc: 'Utiliser Netease Cloud Music en dernier recours lorsque le serveur et LRCLIB ne retournent rien. Meilleure couverture pour la musique asiatique et internationale.',
downloadsTitle: 'Export ZIP & Archivage',
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
downloadsDefault: 'Dossier de téléchargement par défaut',
@@ -567,6 +578,9 @@ export const frTranslation = {
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
preloadMode: 'Précharger la piste suivante',
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
nextTrackBufferingTitle: 'Piste suivante mise en mémoire tampon',
preloadHotCacheMutualExclusive: "Preload et Hot Cache remplissent le même rôle — un seul peut être actif à la fois. Activer l'un désactive automatiquement l'autre.",
preloadOff: 'Désactivé',
preloadBalanced: 'Équilibré (30 s avant la fin)',
preloadEarly: 'Tôt (après 5 s de lecture)',
preloadCustom: 'Personnalisé',
@@ -803,6 +817,7 @@ export const frTranslation = {
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
lyricsSourceServer: 'Source : Serveur',
lyricsSourceLrclib: 'Source : LRCLIB',
lyricsSourceNetease: 'Source : Netease',
},
songInfo: {
title: 'Infos du morceau',
@@ -913,6 +928,10 @@ export const frTranslation = {
favorite: 'Ajouter aux favoris',
unfavorite: 'Retirer des favoris',
noFavorites: 'Aucune station favorite.',
listenerCount_one: '{{count}} auditeur',
listenerCount_other: '{{count}} auditeurs',
recentlyPlayed: 'Récemment joués',
upNext: 'À suivre',
},
folderBrowser: {
empty: 'Dossier vide',
+19
View File
@@ -94,6 +94,8 @@ export const nbTranslation = {
addToQueue: 'Legg til i kø',
enqueueAlbum: 'Legg albumet i kø',
startRadio: 'Start radio',
instantMix: 'Instant Mix',
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
lfmLove: 'Lik på Last.fm',
lfmUnlove: 'Fjern fra likte på Last.fm',
favorite: 'Favoritt',
@@ -361,6 +363,8 @@ export const nbTranslation = {
updaterAurHint: 'Installer oppdateringen via AUR:',
updaterErrorMsg: 'Nedlasting mislyktes',
updaterRetryBtn: 'Prøv igjen',
durationHoursMinutes: '{{hours}} t {{minutes}} min',
durationMinutesOnly: '{{minutes}} min',
updaterOpenGitHub: 'Åpne på GitHub',
},
settings: {
@@ -394,6 +398,11 @@ export const nbTranslation = {
testBtn: 'Test tilkobling',
testingBtn: 'Tester…',
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Slå på hvis denne serveren bruker <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink>. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.',
audiomuseIssueHint:
'Instant Mix feilet nylig — sjekk Navidrome-plugin og AudioMuse API. Lignende artister hentes fra Last.fm hvis serveren ikke returnerer noe.',
connected: 'Tilkoblet',
failed: 'Mislyktes',
eqTitle: 'Jevnstiller',
@@ -474,6 +483,8 @@ export const nbTranslation = {
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
lyricsServerFirst: 'Foretrekk server-sangtekst',
lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.',
enableNeteaselyrics: 'Netease Cloud Music sangtekster',
enableNeteaselyricsDesc: 'Bruk Netease Cloud Music som siste utvei når server og LRCLIB ikke finner noe. Best dekning for asiatisk og internasjonal musikk.',
downloadsTitle: 'ZIP Eksport & Arkivering',
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
downloadsDefault: 'Standard nedlastingsmappe',
@@ -569,6 +580,9 @@ export const nbTranslation = {
experimental: 'Eksperimentell',
preloadMode: 'Forhåndslast neste spor',
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
nextTrackBufferingTitle: 'Neste spor bufring',
preloadHotCacheMutualExclusive: 'Forhåndslasting og Hot Cache tjener samme formål bare én kan være aktiv om gangen. Å aktivere den ene deaktiverer automatisk den andre.',
preloadOff: 'Av',
preloadBalanced: 'Balansert (30 s før slutt)',
preloadEarly: 'Tidlig (etter 5 s avspilling)',
preloadCustom: 'Egendefinert',
@@ -802,6 +816,7 @@ export const nbTranslation = {
lyricsNotFound: 'Ingen sangtekst funnet for dette sporet',
lyricsSourceServer: 'Kilde: Server',
lyricsSourceLrclib: 'Kilde: LRCLIB',
lyricsSourceNetease: 'Kilde: Netease',
},
songInfo: {
title: 'Sanginfo',
@@ -912,6 +927,10 @@ export const nbTranslation = {
favorite: 'Legg til i favoritter',
unfavorite: 'Fjern fra favoritter',
noFavorites: 'Ingen favorittstasjoner.',
listenerCount_one: '{{count}} lytter',
listenerCount_other: '{{count}} lyttere',
recentlyPlayed: 'Nylig spilt',
upNext: 'Neste ut',
},
folderBrowser: {
empty: 'Tom mappe',
+19
View File
@@ -94,6 +94,8 @@ export const nlTranslation = {
addToQueue: 'Aan wachtrij toevoegen',
enqueueAlbum: 'Album in wachtrij',
startRadio: 'Radio starten',
instantMix: 'Instant Mix',
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
lfmLove: 'Liken op Last.fm',
lfmUnlove: 'Niet meer liken op Last.fm',
favorite: 'Favoriet',
@@ -361,6 +363,8 @@ export const nlTranslation = {
updaterAurHint: 'Update installeren via AUR:',
updaterErrorMsg: 'Downloaden mislukt',
updaterRetryBtn: 'Opnieuw proberen',
durationHoursMinutes: '{{hours}} u {{minutes}} min',
durationMinutesOnly: '{{minutes}} min',
updaterOpenGitHub: 'Openen op GitHub',
},
settings: {
@@ -394,6 +398,11 @@ export const nlTranslation = {
testBtn: 'Verbinding testen',
testingBtn: 'Testen…',
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Zet aan als deze server de <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink> gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpaginas.',
audiomuseIssueHint:
'Instant Mix is onlangs mislukt — controleer de Navidrome-plugin en AudioMuse API. Zonder serverresultaten worden vergelijkbare artiesten via Last.fm geladen.',
connected: 'Verbonden',
failed: 'Mislukt',
eqTitle: 'Equalizer',
@@ -475,6 +484,8 @@ export const nlTranslation = {
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
lyricsServerFirst: 'Server-songtekst voorrang geven',
lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.',
enableNeteaselyrics: 'Netease Cloud Music songteksten',
enableNeteaselyricsDesc: 'Gebruik Netease Cloud Music als laatste bron wanneer server en LRCLIB niets opleveren. Beste dekking voor Aziatische en internationale muziek.',
downloadsTitle: 'ZIP-export & Archivering',
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
downloadsDefault: 'Standaard downloadmap',
@@ -567,6 +578,9 @@ export const nlTranslation = {
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
preloadMode: 'Volgend nummer vooraf laden',
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
nextTrackBufferingTitle: 'Volgend nummer buffering',
preloadHotCacheMutualExclusive: 'Preload en Hot Cache dienen hetzelfde doel — slechts één kan tegelijk actief zijn. Het inschakelen van de ene schakelt de andere automatisch uit.',
preloadOff: 'Uit',
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
preloadEarly: 'Vroeg (na 5 s afspelen)',
preloadCustom: 'Aangepast',
@@ -803,6 +817,7 @@ export const nlTranslation = {
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
lyricsSourceServer: 'Bron: Server',
lyricsSourceLrclib: 'Bron: LRCLIB',
lyricsSourceNetease: 'Bron: Netease',
},
songInfo: {
title: 'Nummerinfo',
@@ -913,6 +928,10 @@ export const nlTranslation = {
favorite: 'Toevoegen aan favorieten',
unfavorite: 'Verwijderen uit favorieten',
noFavorites: 'Geen favoriete stations.',
listenerCount_one: '{{count}} luisteraar',
listenerCount_other: '{{count}} luisteraars',
recentlyPlayed: 'Recent gespeeld',
upNext: 'Volgende',
},
folderBrowser: {
empty: 'Lege map',
+24 -5
View File
@@ -21,7 +21,7 @@ export const ruTranslation = {
offlineLibrary: 'Офлайн-библиотека',
genres: 'Жанры',
playlists: 'Плейлисты',
mostPlayed: 'Часто слушаемое',
mostPlayed: 'Популярное',
radio: 'Онлайн-радио',
folderBrowser: 'Браузер папок',
libraryScope: 'Область медиатеки',
@@ -31,7 +31,7 @@ export const ruTranslation = {
hero: 'Подборка',
starred: 'Личное избранное',
recent: 'Недавно добавлено',
mostPlayed: 'Чаще всего',
mostPlayed: 'Популярное',
recentlyPlayed: 'Недавно проиграно',
discover: 'Обзор',
loadMore: 'Ещё',
@@ -95,6 +95,8 @@ export const ruTranslation = {
addToQueue: 'В конец очереди',
enqueueAlbum: 'Альбом в очередь',
startRadio: 'Радио по похожим',
instantMix: 'Instant Mix',
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
lfmLove: 'Любимое на Last.fm',
lfmUnlove: 'Убрать с Last.fm',
favorite: 'В избранное',
@@ -375,6 +377,8 @@ export const ruTranslation = {
updaterAurHint: 'Установить обновление через AUR:',
updaterErrorMsg: 'Ошибка загрузки',
updaterRetryBtn: 'Повторить',
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
durationMinutesOnly: '{{minutes}}мин',
updaterOpenGitHub: 'Открыть на GitHub',
},
settings: {
@@ -409,6 +413,11 @@ export const ruTranslation = {
testBtn: 'Проверить',
testingBtn: 'Проверка…',
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Включите, если на этом сервере настроен <pluginLink>плагин AudioMuse-AI для Navidrome</pluginLink>. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
audiomuseIssueHint:
'Недавно не удалось собрать Instant Mix — проверьте плагин Navidrome и API AudioMuse. Похожие исполнители подтянутся с Last.fm, если сервер ничего не вернёт.',
connected: 'Подключено',
failed: 'Ошибка',
eqTitle: 'Эквалайзер',
@@ -498,6 +507,8 @@ export const ruTranslation = {
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
lyricsServerFirst: 'Предпочитать серверные тексты',
lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы использовать LRCLIB сначала.',
enableNeteaselyrics: 'Тексты Netease Cloud Music',
enableNeteaselyricsDesc: 'Использовать Netease Cloud Music как последний источник текстов, когда сервер и LRCLIB не находят ничего. Лучшее покрытие для азиатской и международной музыки.',
downloadsTitle: 'Экспорт ZIP и архивы',
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
downloadsDefault: 'Папка «Загрузки» по умолчанию',
@@ -595,6 +606,9 @@ export const ruTranslation = {
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
preloadMode: 'Предзагрузка следующего трека',
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
nextTrackBufferingTitle: 'Буферизация следующего трека',
preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.',
preloadOff: 'Выкл.',
preloadBalanced: 'Умеренно (за 30 с до конца)',
preloadEarly: 'Рано (через 5 с после старта)',
preloadCustom: 'Свой интервал',
@@ -794,8 +808,8 @@ export const ruTranslation = {
statAlbums: 'Альбомы',
statSongs: 'Треки',
statGenres: 'Жанры',
statPlaytime: 'Всего прослушано',
genreInsights: 'Жанры подробнее',
statPlaytime: 'Время звучания',
genreInsights: 'По жанрам',
formatDistribution: 'Форматы',
formatSample: 'Выборка {{n}} треков',
computing: 'Считаем…',
@@ -862,6 +876,7 @@ export const ruTranslation = {
lyrics: 'Текст',
lyricsLoading: 'Загрузка текста…',
lyricsNotFound: 'Текст не найден',
lyricsSourceNetease: 'Источник: Netease',
},
songInfo: {
title: 'О треке',
@@ -933,7 +948,7 @@ export const ruTranslation = {
downloadZip: 'Скачать (ZIP)',
},
mostPlayed: {
title: 'Часто слушаемое',
title: 'Популярное',
topArtists: 'Топ исполнителей',
topAlbums: 'Топ альбомов',
plays: '{{n}} прослушиваний',
@@ -972,6 +987,10 @@ export const ruTranslation = {
favorite: 'В избранное',
unfavorite: 'Убрать из избранного',
noFavorites: 'Избранных станций нет.',
listenerCount_one: '{{count}} слушатель',
listenerCount_other: '{{count}} слушателей',
recentlyPlayed: 'Недавно сыгранное',
upNext: 'Следующий',
},
folderBrowser: {
empty: 'Папка пуста',
+20 -1
View File
@@ -94,6 +94,8 @@ export const zhTranslation = {
addToQueue: '添加到队列',
enqueueAlbum: '专辑加入队列',
startRadio: '开始电台',
instantMix: '即时混音',
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
lfmLove: '在 Last.fm 上标记喜欢',
lfmUnlove: '取消 Last.fm 喜欢标记',
favorite: '收藏',
@@ -357,6 +359,8 @@ export const zhTranslation = {
updaterAurHint: '通过 AUR 安装更新:',
updaterErrorMsg: '下载失败',
updaterRetryBtn: '重试',
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
durationMinutesOnly: '{{minutes}}分钟',
updaterOpenGitHub: '在 GitHub 上打开',
},
settings: {
@@ -390,6 +394,11 @@ export const zhTranslation = {
testBtn: '测试连接',
testingBtn: '正在测试…',
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AINavidrome',
audiomuseDesc:
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
audiomuseIssueHint:
'近期即时混音失败 — 请检查 Navidrome 插件与 AudioMuse API。若服务器无结果,将回退使用 Last.fm 的相似艺人。',
connected: '已连接',
failed: '失败',
eqTitle: '均衡器',
@@ -471,6 +480,8 @@ export const zhTranslation = {
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
lyricsServerFirst: '优先使用服务器歌词',
lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。',
enableNeteaselyrics: '网易云音乐歌词',
enableNeteaselyricsDesc: '当服务器和 LRCLIB 均无结果时,使用网易云音乐作为最终歌词来源。对亚洲及国际音乐覆盖最佳。',
downloadsTitle: 'ZIP 导出与归档',
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
downloadsDefault: '默认下载文件夹',
@@ -563,6 +574,9 @@ export const zhTranslation = {
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
preloadMode: '预加载下一曲目',
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
nextTrackBufferingTitle: '下一曲目缓冲',
preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。',
preloadOff: '关闭',
preloadBalanced: '均衡(结束前30秒)',
preloadEarly: '提前(播放5秒后)',
preloadCustom: '自定义',
@@ -739,7 +753,7 @@ export const zhTranslation = {
statAlbums: '专辑',
statSongs: '歌曲',
statGenres: '流派',
statPlaytime: '总播放时长',
statPlaytime: '音频总时长',
genreInsights: '流派洞察',
formatDistribution: '格式分布',
formatSample: '{{n}} 首曲目的样本',
@@ -799,6 +813,7 @@ export const zhTranslation = {
lyricsNotFound: '未找到此曲目的歌词',
lyricsSourceServer: '来源:服务器',
lyricsSourceLrclib: '来源:LRCLIB',
lyricsSourceNetease: '来源:网易云',
},
songInfo: {
title: '歌曲信息',
@@ -909,6 +924,10 @@ export const zhTranslation = {
favorite: '添加到收藏',
unfavorite: '从收藏移除',
noFavorites: '没有收藏的电台。',
listenerCount_one: '{{count}} 位听众',
listenerCount_other: '{{count}} 位听众',
recentlyPlayed: '最近播放',
upNext: '即将播放',
},
folderBrowser: {
empty: '空文件夹',
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Play, SlidersHorizontal } from 'lucide-react';
import { Play, SlidersVertical } from 'lucide-react';
import {
search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs,
SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong,
@@ -140,7 +140,7 @@ export default function AdvancedSearch() {
<div className="content-body animate-fade-in">
<div style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<SlidersHorizontal size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<SlidersVertical size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
{t('search.advanced')}
</h1>
</div>
+84 -13
View File
@@ -57,6 +57,7 @@ export default function ArtistDetail() {
const [openedLink, setOpenedLink] = useState<string | null>(null);
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
const [similarLoading, setSimilarLoading] = useState(false);
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
const [featuredLoading, setFeaturedLoading] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [bioExpanded, setBioExpanded] = useState(false);
@@ -73,6 +74,9 @@ export default function ArtistDetail() {
const downloadArtist = useOfflineStore(s => s.downloadArtist);
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
@@ -95,12 +99,6 @@ export default function ArtistDetail() {
// Render the page immediately from local data
setLoading(false);
// Fetch artist info (may trigger slow external lookup on the server)
// and top songs in the background — do not block rendering
getArtistInfo(id).then(artistInfo => {
if (!cancelled) setInfo(artistInfo ?? null);
}).catch(() => {});
getTopSongs(artistData.artist.name).then(songsData => {
if (!cancelled) setTopSongs(songsData ?? []);
}).catch(() => {});
@@ -110,6 +108,23 @@ export default function ArtistDetail() {
return () => { cancelled = true; };
}, [id]);
useEffect(() => {
if (!id) return;
let cancelled = false;
setArtistInfoLoading(true);
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
.then(artistInfo => {
if (!cancelled) setInfo(artistInfo ?? null);
})
.catch(() => {
if (!cancelled) setInfo(null);
})
.finally(() => {
if (!cancelled) setArtistInfoLoading(false);
});
return () => { cancelled = true; };
}, [id, audiomuseNavidromeEnabled]);
useEffect(() => {
if (!id) return;
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
@@ -174,7 +189,7 @@ export default function ArtistDetail() {
}, [artist?.id, musicLibraryFilterVersion]);
useEffect(() => {
if (!artist || !lastfmIsConfigured()) return;
if (!artist || audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
setSimilarArtists([]);
setSimilarLoading(true);
lastfmGetSimilarArtists(artist.name).then(async names => {
@@ -197,7 +212,52 @@ export default function ArtistDetail() {
setSimilarArtists(found);
setSimilarLoading(false);
}).catch(() => setSimilarLoading(false));
}, [artist?.id, musicLibraryFilterVersion]);
}, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled]);
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
useEffect(() => {
if (!artist || !audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
if (artistInfoLoading) return;
if ((info?.similarArtist?.length ?? 0) > 0) 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,
artist?.name,
musicLibraryFilterVersion,
audiomuseNavidromeEnabled,
artistInfoLoading,
info?.similarArtist?.length,
]);
useEffect(() => {
if (!audiomuseNavidromeEnabled) return;
if ((info?.similarArtist?.length ?? 0) > 0) {
setSimilarArtists([]);
setSimilarLoading(false);
}
}, [id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
const openLink = (url: string, key: string) => {
open(url);
@@ -331,6 +391,18 @@ export default function ArtistDetail() {
const coverId = artist.coverArt || artist.id;
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
id: sa.id,
name: sa.name,
albumCount: sa.albumCount,
}));
const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0;
const showLastfmSimilar =
lastfmIsConfigured() &&
(!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) &&
(similarLoading || similarArtists.length > 0);
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
return (
<div className="content-body animate-fade-in">
<button
@@ -564,20 +636,19 @@ export default function ArtistDetail() {
</>
)}
{/* Similar Artists (Last.fm) */}
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
{showSimilarSection && (
<>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.similarArtists')}
</h2>
{similarLoading ? (
{showLastfmSimilar && 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 => (
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists).map(a => (
<button
key={a.id}
className="artist-ext-link"
@@ -592,7 +663,7 @@ export default function ArtistDetail() {
)}
{/* Albums */}
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}>
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || showSimilarSection) ? '2rem' : '0', marginBottom: '1rem' }}>
{t('artistDetail.albumsBy', { name: artist.name })}
</h2>
+4 -1
View File
@@ -43,7 +43,10 @@ export default function Favorites() {
const [ratings, setRatings] = useState<Record<string, number>>({});
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const playRadio = usePlayerStore(s => s.playRadio);
const stop = usePlayerStore(s => s.stop);
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying);
+4 -1
View File
@@ -19,7 +19,10 @@ import { showToast } from '../utils/toast';
export default function InternetRadio() {
const { t } = useTranslation();
const { playRadio, stop, currentRadio, isPlaying } = usePlayerStore();
const playRadio = usePlayerStore(s => s.playRadio);
const stop = usePlayerStore(s => s.stop);
const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying);
const [stations, setStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
+18 -5
View File
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { pingWithCredentials } from '../api/subsonic';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
const PsysonicLogo = () => (
@@ -36,16 +36,16 @@ export default function Login() {
// Test connection directly with entered credentials — don't touch the store yet.
// This avoids any race condition with Zustand's async store rehydration.
let ok = false;
let ping: Awaited<ReturnType<typeof pingWithCredentials>> = { ok: false };
try {
ok = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
ping = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
} catch {
ok = false;
ping = { ok: false };
}
setConnecting(false);
if (ok) {
if (ping.ok) {
// Connection succeeded — now persist to store
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
let serverId: string;
@@ -63,6 +63,19 @@ export default function Login() {
password: profile.password,
});
}
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
useAuthStore.getState().setSubsonicServerIdentity(serverId, identity);
scheduleInstantMixProbeForServer(
serverId,
profile.url.trim(),
profile.username.trim(),
profile.password,
identity,
);
setActiveServer(serverId);
setLoggedIn(true);
setStatus('ok');
+137 -5
View File
@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react';
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import {
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
@@ -10,6 +11,7 @@ import {
SubsonicSong, SubsonicArtistInfo,
} from '../api/subsonic';
import { useCachedUrl } from '../components/CachedImage';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -211,15 +213,22 @@ export default function NowPlaying() {
const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const isPlaying = usePlayerStore(s => s.isPlaying);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
);
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
// Radio metadata (ICY or AzuraCast)
const radioMeta = useRadioMetadata(currentRadio ?? null);
// Extra song metadata
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
useEffect(() => {
@@ -231,8 +240,10 @@ export default function NowPlaying() {
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
useEffect(() => {
if (!currentTrack?.artistId) { setArtistInfo(null); return; }
getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null));
}, [currentTrack?.artistId]);
getArtistInfo(currentTrack.artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
.then(setArtistInfo)
.catch(() => setArtistInfo(null));
}, [currentTrack?.artistId, audiomuseNavidromeEnabled]);
// Album tracks
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
@@ -259,15 +270,136 @@ export default function NowPlaying() {
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Radio cover
const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : '';
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : '';
const resolvedRadioCover = useCachedUrl(radioCoverFetchUrl, radioCoverKey);
const similarArtists = artistInfo?.similarArtist ?? [];
// ── Radio now-playing section ────────────────────────────────────────────────
const radioNowPlaying = currentRadio && !currentTrack && (
<div className="np-radio-section">
{/* Station hero */}
<div className="np-hero-card">
<div className="np-hero-left">
<div className="np-hero-info">
<div className="np-title" style={{ color: 'var(--accent)' }}>
{currentRadio.name}
</div>
{radioMeta.currentTitle && (
<div className="np-artist-album">
{radioMeta.currentArtist && (
<><span className="np-link">{radioMeta.currentArtist}</span><span className="np-sep">·</span></>
)}
<span>{radioMeta.currentTitle}</span>
{radioMeta.currentAlbum && (
<><span className="np-sep">·</span><span style={{ opacity: 0.6 }}>{radioMeta.currentAlbum}</span></>
)}
</div>
)}
<div className="np-tech-row">
<span className="np-badge np-badge-live">
<Radio size={10} style={{ marginRight: 3 }} />{t('radio.live')}
</span>
{radioMeta.source === 'azuracast' && (
<span className="np-badge np-badge-azuracast">AzuraCast</span>
)}
{radioMeta.listeners != null && (
<span className="np-badge">
<Users size={10} style={{ marginRight: 3 }} />
{t('radio.listenerCount', { count: radioMeta.listeners })}
</span>
)}
</div>
{/* AzuraCast progress bar */}
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
<div className="np-radio-progress-wrap">
<span className="np-radio-time">{formatTime(radioMeta.elapsed)}</span>
<div className="np-radio-progress-bar">
<div
className="np-radio-progress-fill"
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
/>
</div>
<span className="np-radio-time">{formatTime(radioMeta.duration)}</span>
</div>
)}
</div>
</div>
{/* Cover */}
<div className="np-hero-cover-wrap">
{resolvedRadioCover
? <img src={resolvedRadioCover} alt={currentRadio.name} className="np-cover" />
: radioMeta.currentArt
? <img src={radioMeta.currentArt} alt="" className="np-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
: <div className="np-cover np-cover-fallback"><Cast size={52} /></div>
}
</div>
{/* Placeholder to keep 3-column layout */}
<div style={{ flex: 1 }} />
</div>
{/* Upcoming track */}
{radioMeta.nextSong && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">
<SkipForward size={13} style={{ marginRight: 5 }} />{t('radio.upNext')}
</h3>
</div>
<div className="np-radio-next-track">
{radioMeta.nextSong.art && (
<img src={radioMeta.nextSong.art} alt="" className="np-radio-track-art"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
<div className="np-radio-track-info">
<span className="np-radio-track-title">{radioMeta.nextSong.title}</span>
{radioMeta.nextSong.artist && (
<span className="np-radio-track-artist">{radioMeta.nextSong.artist}</span>
)}
</div>
</div>
</div>
)}
{/* Song history */}
{radioMeta.history.length > 0 && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">
<Clock size={13} style={{ marginRight: 5 }} />{t('radio.recentlyPlayed')}
</h3>
</div>
<div className="np-album-tracklist">
{radioMeta.history.map((item, idx) => (
<div key={idx} className="np-album-track">
{item.song.art && (
<img src={item.song.art} alt="" className="np-radio-track-art np-radio-track-art--sm"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
<span className="np-album-track-title truncate">
{item.song.artist ? `${item.song.artist}${item.song.title}` : item.song.title}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
return (
<div className="np-page">
<div className="np-main">
{currentTrack ? (
{radioNowPlaying ? (
radioNowPlaying
) : currentTrack ? (
<>
{/* ── Hero Card ── */}
<div className="np-hero-card">
+2 -3
View File
@@ -23,6 +23,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import StarRating from '../components/StarRating';
function sanitizeFilename(name: string): string {
@@ -41,9 +42,7 @@ function formatDuration(seconds: number): string {
function totalDurationLabel(songs: SubsonicSong[]): string {
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
return formatHumanHoursMinutes(total);
}
function codecLabel(song: SubsonicSong): string {
+3 -5
View File
@@ -6,18 +6,16 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
return formatHumanHoursMinutes(seconds);
}
export default function Playlists() {
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack } = usePlayerStore();
const playTrack = usePlayerStore(s => s.playTrack);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const removeId = usePlaylistStore((s) => s.removeId);
+294 -205
View File
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle
} from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
@@ -29,14 +29,17 @@ import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../st
import { useHomeStore, HomeSectionId } from '../store/homeStore';
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
import { ALL_NAV_ITEMS } from '../components/Sidebar';
import { pingWithCredentials } from '../api/subsonic';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import Equalizer from '../components/Equalizer';
import StarRating from '../components/StarRating';
import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
const CONTRIBUTORS = [
{
github: 'jiezhuo',
@@ -97,6 +100,7 @@ const CONTRIBUTORS = [
'Per-server music folder filter and sidebar library picker (PR #124, PR #125)',
'Richer star ratings, skip threshold, and library filtering (PR #130)',
'Statistics: scope album and song totals to selected music library (PR #138)',
'AudioMuse-AI discovery integration for Navidrome (PR #147)',
],
},
{
@@ -116,6 +120,7 @@ const CONTRIBUTORS = [
contributions: [
'Nightfox.nvim theme group in Open Source Classics (PR #114)',
'Switch reqwest to rustls-tls for cross-platform TLS (PR #112)',
'ICY stream metadata & AzuraCast Now Playing support (PR #146)',
],
},
] as const;
@@ -248,6 +253,10 @@ export default function Settings() {
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
useEffect(() => {
if (activeTab === 'audio') {
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
return;
}
if (activeTab !== 'storage') return;
getImageCacheSize().then(setImageCacheBytes);
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
@@ -313,8 +322,17 @@ export default function Settings() {
const testConnection = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
try {
const ok = await pingWithCredentials(server.url, server.username, server.password);
setConnStatus(s => ({ ...s, [server.id]: ok ? 'ok' : 'error' }));
const ping = await pingWithCredentials(server.url, server.username, server.password);
if (ping.ok) {
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
auth.setSubsonicServerIdentity(server.id, identity);
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
}
setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' }));
} catch {
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
}
@@ -323,8 +341,15 @@ export default function Settings() {
const switchToServer = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
try {
const ok = await pingWithCredentials(server.url, server.username, server.password);
if (ok) {
const ping = await pingWithCredentials(server.url, server.username, server.password);
if (ping.ok) {
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
auth.setSubsonicServerIdentity(server.id, identity);
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
auth.setActiveServer(server.id);
auth.setLoggedIn(true);
navigate('/');
@@ -347,9 +372,16 @@ export default function Settings() {
const tempId = '_new';
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
try {
const ok = await pingWithCredentials(data.url, data.username, data.password);
if (ok) {
const ping = await pingWithCredentials(data.url, data.username, data.password);
if (ping.ok) {
const id = auth.addServer(data);
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
auth.setSubsonicServerIdentity(id, identity);
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
auth.setActiveServer(id);
auth.setLoggedIn(true);
setConnStatus(s => ({ ...s, [id]: 'ok' }));
@@ -523,8 +555,19 @@ export default function Settings() {
<span className="toggle-track" />
</label>
</div>
</div>
</section>
<div className="divider" />
{/* Next Track Buffering */}
<section className="settings-section">
<div className="settings-section-header">
<Download size={18} />
<h2>{t('settings.nextTrackBufferingTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
{t('settings.preloadHotCacheMutualExclusive')}
</div>
{/* Preload mode */}
<div className="settings-toggle-row">
@@ -532,31 +575,165 @@ export default function Settings() {
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
</div>
</div>
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{(['balanced', 'early', 'custom'] as const).map(mode => (
<button
key={mode}
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setPreloadMode(mode)}
>
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
</button>
))}
</div>
{auth.preloadMode === 'custom' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<label className="toggle-switch" aria-label={t('settings.preloadMode')}>
<input
type="range"
min={5} max={120} step={5}
value={auth.preloadCustomSeconds}
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
style={{ width: 120 }}
type="checkbox"
checked={auth.preloadMode !== 'off'}
onChange={e => {
if (e.target.checked) {
auth.setPreloadMode('balanced');
if (auth.hotCacheEnabled) auth.setHotCacheEnabled(false);
} else {
auth.setPreloadMode('off');
}
}}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
</span>
<span className="toggle-track" />
</label>
</div>
{auth.preloadMode !== 'off' && (
<>
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{(['balanced', 'early', 'custom'] as const).map(mode => (
<button
key={mode}
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setPreloadMode(mode)}
>
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
</button>
))}
</div>
{auth.preloadMode === 'custom' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={5} max={120} step={5}
value={auth.preloadCustomSeconds}
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
style={{ width: 120 }}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
</span>
</div>
)}
</>
)}
<div className="divider" />
{/* Hot Cache */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
<input
type="checkbox"
checked={auth.hotCacheEnabled}
onChange={async e => {
const enabled = e.target.checked;
if (!enabled) {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
setHotCacheBytes(0);
auth.setHotCacheEnabled(false);
} else {
auth.setHotCacheEnabled(true);
if (auth.preloadMode !== 'off') auth.setPreloadMode('off');
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
}
}}
id="hot-cache-enabled-toggle"
/>
<span className="toggle-track" />
</label>
</div>
{auth.hotCacheEnabled && (
<div style={{ marginTop: '1.25rem' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
type="text"
readOnly
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
style={{ flex: 1, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.hotCacheDownloadDir && (
<button
type="button"
className="btn btn-ghost"
onClick={() => {
auth.setHotCacheDownloadDir('');
useHotCacheStore.setState({ entries: {} });
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}}
data-tooltip={t('settings.hotCacheDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
</button>
</div>
{auth.hotCacheDownloadDir && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
{t('settings.hotCacheDirHint')}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
{hotCacheTrackCount}
</div>
</div>
<div>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-max-mb-slider" />
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 72 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-debounce-slider" />
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
? t('settings.hotCacheDebounceImmediate')
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
</span>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13 }}
onClick={async () => {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
setHotCacheBytes(b);
}}
>
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
</button>
</div>
)}
@@ -567,17 +744,7 @@ export default function Settings() {
<section className="settings-section">
<div className="settings-section-header">
<Waves size={18} />
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{t('settings.hiResTitle')}
<span style={{
fontSize: 10, fontWeight: 600, textTransform: 'uppercase',
letterSpacing: '0.04em', padding: '2px 6px', borderRadius: 4,
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
color: 'var(--text-primary)',
}}>
{t('settings.hotCacheAlphaBadge')}
</span>
</h2>
<h2>{t('settings.hiResTitle')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
@@ -706,6 +873,17 @@ export default function Settings() {
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.enableNeteaselyrics')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableNeteaselyricsDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.enableNeteaselyrics')}>
<input type="checkbox" checked={auth.enableNeteaselyrics} onChange={e => auth.setEnableNeteaselyrics(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
@@ -1001,164 +1179,6 @@ export default function Settings() {
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<HardDrive size={18} />
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{t('settings.hotCacheTitle')}
<span
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 6px',
borderRadius: 4,
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.hotCacheAlphaBadge')}
</span>
</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
{t('settings.hotCacheDisclaimer')}
</div>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheEnabled')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
<input
type="checkbox"
checked={auth.hotCacheEnabled}
onChange={async e => {
const enabled = e.target.checked;
if (!enabled) {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
setHotCacheBytes(0);
auth.setHotCacheEnabled(false);
} else {
auth.setHotCacheEnabled(true);
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
}
}}
id="hot-cache-enabled-toggle"
/>
<span className="toggle-track" />
</label>
</div>
{auth.hotCacheEnabled && (
<div style={{ marginTop: '1.25rem' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
type="text"
readOnly
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
style={{ flex: 1, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.hotCacheDownloadDir && (
<button
type="button"
className="btn btn-ghost"
onClick={() => {
auth.setHotCacheDownloadDir('');
useHotCacheStore.setState({ entries: {} });
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}}
data-tooltip={t('settings.hotCacheDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
</button>
</div>
{auth.hotCacheDownloadDir && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
{t('settings.hotCacheDirHint')}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
{hotCacheTrackCount}
</div>
</div>
<div>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={32}
max={20000}
step={32}
value={snapHotCacheMb(auth.hotCacheMaxMb)}
onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))}
style={{ width: 140 }}
id="hot-cache-max-mb-slider"
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 72 }}>
{snapHotCacheMb(auth.hotCacheMaxMb)} MB
</span>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={0}
max={600}
step={1}
value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))}
onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))}
style={{ width: 140 }}
id="hot-cache-debounce-slider"
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
? t('settings.hotCacheDebounceImmediate')
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
</span>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13 }}
onClick={async () => {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
setHotCacheBytes(b);
}}
>
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
</button>
</div>
)}
</div>
</section>
{/* ZIP Export & Archiving */}
<section className="settings-section">
<div className="settings-section-header">
@@ -1310,7 +1330,11 @@ export default function Settings() {
<h2>{t('settings.uiScaleTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* TODO: UI scaling is being reworked — disabled until fixed */}
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
Interface scaling is currently being reworked and will be available in a future update.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', opacity: 0.4, pointerEvents: 'none', marginTop: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
@@ -1425,7 +1449,7 @@ export default function Settings() {
</div>
<div className="settings-card">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
{t('settings.shortcutsReset')}
</button>
</div>
@@ -1511,7 +1535,7 @@ export default function Settings() {
</p>
<div className="settings-card">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
{t('settings.shortcutsReset')}
</button>
</div>
@@ -1656,6 +1680,71 @@ export default function Settings() {
</button>
</div>
</div>
{showAudiomuseNavidromeServerSetting(
auth.subsonicServerIdentityByServer[srv.id],
auth.instantMixProbeByServer[srv.id],
) && (
<div
className="settings-toggle-row"
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
<Sparkles size={16} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{t('settings.audiomuseTitle')}
<span
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 6px',
borderRadius: 4,
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.hotCacheAlphaBadge')}
</span>
{!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && (
<AlertTriangle
size={16}
style={{ color: 'var(--color-warning, #f59e0b)', flexShrink: 0 }}
data-tooltip={t('settings.audiomuseIssueHint')}
aria-label={t('settings.audiomuseIssueHint')}
/>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
<Trans
i18nKey="settings.audiomuseDesc"
components={{
pluginLink: (
<a
href={AUDIOMUSE_NV_PLUGIN_URL}
onClick={e => {
e.preventDefault();
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
}}
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
/>
),
}}
/>
</div>
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
<input
type="checkbox"
checked={!!auth.audiomuseNavidromeByServer[srv.id]}
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
)}
</div>
);
})}
+48 -69
View File
@@ -1,5 +1,13 @@
import React, { useEffect, useState } from 'react';
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
import {
fetchStatisticsFormatSample,
fetchStatisticsLibraryAggregates,
fetchStatisticsOverview,
getAlbumList,
SubsonicAlbum,
SubsonicGenre,
} from '../api/subsonic';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import AlbumRow from '../components/AlbumRow';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
@@ -15,13 +23,6 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string)
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
}
function formatPlaytime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h.toLocaleString()}h ${m}m`;
return `${m}m`;
}
const PERIODS: { key: LastfmPeriod; label: string }[] = [
{ key: '7day', label: 'lfmPeriod7day' },
{ key: '1month', label: 'lfmPeriod1month' },
@@ -59,81 +60,59 @@ export default function Statistics() {
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
useEffect(() => {
Promise.all([
getAlbumList('recent', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('highest', 12).catch(() => []),
getArtists().catch(() => []),
getGenres().catch(() => []),
]).then(([rc, fr, hi, a, g]) => {
setRecent(rc);
setFrequent(fr);
setHighest(hi);
setArtistCount(a.length);
// Album/song totals come from paginated getAlbumList (see playtime effect) — getGenres is not musicFolder-scoped.
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
setGenres(sorted);
setLoading(false);
}).catch(() => setLoading(false));
fetchStatisticsOverview()
.then(d => {
setRecent(d.recent);
setFrequent(d.frequent);
setHighest(d.highest);
setArtistCount(d.artistCount);
setLoading(false);
})
.catch(() => setLoading(false));
}, [musicLibraryFilterVersion]);
// Background: playtime + album/song counts (same paginated list as library filter; caps at 5000 albums)
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
useEffect(() => {
let cancelled = false;
setTotalPlaytime(null);
setTotalAlbums(null);
setTotalSongs(null);
setPlaytimeCapped(false);
setGenres([]);
(async () => {
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
let offset = 0;
const pageSize = 500;
const maxPages = 10;
let capped = false;
for (let page = 0; page < maxPages; page++) {
try {
const albums = await getAlbumList('newest', pageSize, offset);
if (cancelled) return;
for (const a of albums) {
playtimeSec += a.duration ?? 0;
albumsCounted += 1;
songsCounted += a.songCount ?? 0;
}
if (albums.length < pageSize) break;
if (page === maxPages - 1) capped = true;
offset += pageSize;
} catch {
break;
try {
const agg = await fetchStatisticsLibraryAggregates();
if (cancelled) return;
setTotalPlaytime(agg.playtimeSec);
setTotalAlbums(agg.albumsCounted);
setTotalSongs(agg.songsCounted);
setPlaytimeCapped(agg.capped);
setGenres(agg.genres);
} catch {
if (!cancelled) {
setTotalPlaytime(0);
setTotalAlbums(0);
setTotalSongs(0);
setPlaytimeCapped(false);
setGenres([]);
}
}
if (!cancelled) {
setTotalPlaytime(playtimeSec);
setTotalAlbums(albumsCounted);
setTotalSongs(songsCounted);
setPlaytimeCapped(capped);
}
})();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion]);
// Background fetch: format distribution (sample of 500 random songs)
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
useEffect(() => {
let cancelled = false;
getRandomSongs(500).then(songs => {
if (cancelled) return;
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const sorted = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
setFormatData(sorted);
setFormatSampleSize(songs.length);
}).catch(() => {});
setFormatData(null);
setFormatSampleSize(0);
fetchStatisticsFormatSample()
.then(s => {
if (cancelled) return;
setFormatData(s.rows);
setFormatSampleSize(s.sampleSize);
})
.catch(() => {});
return () => { cancelled = true; };
}, [musicLibraryFilterVersion]);
@@ -176,7 +155,7 @@ export default function Statistics() {
const playtimeDisplay = totalPlaytime === null
? t('statistics.computing')
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
: (playtimeCapped ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime);
const countDisplay = (n: number | null) =>
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
@@ -220,10 +199,10 @@ export default function Statistics() {
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{topGenres.map(g => (
<div key={g.value}>
<div key={g.value || '__genre_unknown__'}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
{g.value}
{g.value.trim() ? g.value : t('statistics.decadeUnknown')}
</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
{g.songCount.toLocaleString()}
+99 -3
View File
@@ -2,6 +2,11 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import type { EntityRatingSupportLevel } from '../api/subsonic';
import {
isNavidromeAudiomuseSoftwareEligible,
type InstantMixProbeResult,
type SubsonicServerIdentity,
} from '../utils/subsonicServerIdentity';
import { usePlayerStore } from './playerStore';
export interface ServerProfile {
@@ -37,7 +42,7 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
preloadMode: 'balanced' | 'early' | 'custom';
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number;
infiniteQueueEnabled: boolean;
showArtistImages: boolean;
@@ -48,6 +53,7 @@ interface AuthState {
useCustomTitlebar: boolean;
nowPlayingEnabled: boolean;
lyricsServerFirst: boolean;
enableNeteaselyrics: boolean;
showFullscreenLyrics: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
@@ -104,6 +110,27 @@ interface AuthState {
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
/**
* Per server: Navidrome has the AudioMuse-AI plugin use `getSimilarSongs` (Instant Mix) and
* `getArtistInfo2` similar artists instead of Last.fm for discovery on this server.
*/
audiomuseNavidromeByServer: Record<string, boolean>;
setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void;
/** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */
subsonicServerIdentityByServer: Record<string, SubsonicServerIdentity>;
setSubsonicServerIdentity: (serverId: string, identity: SubsonicServerIdentity) => void;
/** Instant Mix / similar path failed while this server had AudioMuse enabled (cleared on success or toggle off). */
audiomuseNavidromeIssueByServer: Record<string, boolean>;
setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void;
/**
* `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection.
*/
instantMixProbeByServer: Record<string, InstantMixProbeResult>;
setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void;
// Status
isLoggedIn: boolean;
isConnecting: boolean;
@@ -133,7 +160,7 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void;
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
setPreloadCustomSeconds: (v: number) => void;
setInfiniteQueueEnabled: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
@@ -144,6 +171,7 @@ interface AuthState {
setUseCustomTitlebar: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setLyricsServerFirst: (v: boolean) => void;
setEnableNeteaselyrics: (v: boolean) => void;
setShowFullscreenLyrics: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
@@ -230,6 +258,7 @@ export const useAuthStore = create<AuthState>()(
useCustomTitlebar: false,
nowPlayingEnabled: false,
lyricsServerFirst: true,
enableNeteaselyrics: false,
showFullscreenLyrics: true,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
@@ -250,6 +279,10 @@ export const useAuthStore = create<AuthState>()(
musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0,
entityRatingSupportByServer: {},
audiomuseNavidromeByServer: {},
subsonicServerIdentityByServer: {},
audiomuseNavidromeIssueByServer: {},
instantMixProbeByServer: {},
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -272,11 +305,19 @@ export const useAuthStore = create<AuthState>()(
const newServers = s.servers.filter(srv => srv.id !== id);
const switchedAway = s.activeServerId === id;
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
const { [id]: _idn, ...identityRest } = s.subsonicServerIdentityByServer;
const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer;
const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer;
return {
servers: newServers,
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
isLoggedIn: switchedAway ? false : s.isLoggedIn,
entityRatingSupportByServer: entityRatingRest,
audiomuseNavidromeByServer: audiomuseRest,
subsonicServerIdentityByServer: identityRest,
audiomuseNavidromeIssueByServer: issueRest,
instantMixProbeByServer: probeRest,
};
});
},
@@ -315,7 +356,7 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setShowArtistImages: (v) => set({ showArtistImages: v }),
@@ -326,6 +367,7 @@ export const useAuthStore = create<AuthState>()(
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
@@ -403,6 +445,60 @@ export const useAuthStore = create<AuthState>()(
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
})),
setAudiomuseNavidromeEnabled: (serverId, enabled) =>
set(s => {
const audiomuseNavidromeByServer = enabled
? { ...s.audiomuseNavidromeByServer, [serverId]: true }
: (() => {
const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer;
return rest;
})();
const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer;
return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest };
}),
setSubsonicServerIdentity: (serverId, identity) =>
set(s => {
const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } };
if (!isNavidromeAudiomuseSoftwareEligible(identity)) {
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer;
return {
subsonicServerIdentityByServer,
audiomuseNavidromeByServer: audiomuseRest,
audiomuseNavidromeIssueByServer: issueRest,
instantMixProbeByServer: probeRest,
};
}
return { subsonicServerIdentityByServer };
}),
setInstantMixProbe: (serverId, result) =>
set(s => {
const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result };
if (result === 'empty') {
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
return {
instantMixProbeByServer,
audiomuseNavidromeByServer: audiomuseRest,
audiomuseNavidromeIssueByServer: issueRest,
};
}
return { instantMixProbeByServer };
}),
setAudiomuseNavidromeIssue: (serverId, hasIssue) =>
set(s =>
hasIssue
? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } }
: (() => {
const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer;
return { audiomuseNavidromeIssueByServer: rest };
})(),
),
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
getBaseUrl: () => {
+79 -48
View File
@@ -117,6 +117,7 @@ interface PlayerState {
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
syncLastfmLovedTracks: () => Promise<void>;
resetAudioPause: () => void;
initializeFromServerQueue: () => Promise<void>;
contextMenu: {
@@ -271,10 +272,10 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
useHotCacheStore.getState().touchPlayed(trackId, serverId);
}
// Track ID that has already been sent to audio_chain_preload / audio_preload.
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over
// the last 30 seconds of a track, each spawning its own HTTP download.
// Track ID that has already been sent to audio_chain_preload (gapless chain).
let gaplessPreloadingId: string | null = null;
// Track ID that has already been sent to audio_preload (byte pre-download).
let bytePreloadingId: string | null = null;
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -328,48 +329,57 @@ function handleAudioProgress(current_time: number, duration: number) {
}
// Pre-buffer / pre-chain next track based on preload mode.
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
const { gaplessEnabled, preloadMode, preloadCustomSeconds, hotCacheEnabled } = useAuthStore.getState();
const remaining = dur - current_time;
const shouldPreload = preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0; // balanced (default)
if (shouldPreload) {
// Gapless chain: always triggers at 30s regardless of preloadMode.
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
// Byte pre-download: skip when Hot Cache is active (it already handles buffering).
const shouldBytePreload = !hotCacheEnabled && preloadMode !== 'off' && (
preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0 // balanced (default)
);
if (shouldChainGapless || shouldBytePreload) {
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 && nextTrack.id !== gaplessPreloadingId) {
if (!nextTrack || nextTrack.id === track.id) return;
const serverId = useAuthStore.getState().activeServerId ?? '';
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — runs early so bytes are cached by chain time.
if (shouldBytePreload && nextTrack.id !== bytePreloadingId) {
bytePreloadingId = nextTrack.id;
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
}
// Gapless chain — decode + chain into Sink 30s before track boundary.
if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id;
const serverId = useAuthStore.getState().activeServerId ?? '';
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
if (gaplessEnabled) {
// Gapless ON: decode + chain directly into the Sink now, 30 s in
// advance. By the time the track boundary arrives, the next source is
// already live — no IPC round-trip at the gap point.
const authState = useAuthStore.getState();
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album'
? nextTrack.replayGainAlbumDb
: nextTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled
? (nextTrack.replayGainPeak ?? null)
: null;
invoke('audio_chain_preload', {
url: nextUrl,
volume: store.volume,
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
hiResEnabled: useAuthStore.getState().enableHiRes,
}).catch(() => {});
} else {
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
}
const authState = useAuthStore.getState();
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album'
? (nextTrack.replayGainAlbumDb ?? nextTrack.replayGainTrackDb)
: nextTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled
? (nextTrack.replayGainPeak ?? null)
: null;
invoke('audio_chain_preload', {
url: nextUrl,
volume: store.volume,
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
hiResEnabled: authState.enableHiRes,
}).catch(() => {});
}
}
}
@@ -407,7 +417,7 @@ function handleAudioEnded() {
*/
function handleAudioTrackSwitched(duration: number) {
lastGaplessSwitchTime = Date.now();
gaplessPreloadingId = null; // allow preloading for the track after this one
gaplessPreloadingId = null; bytePreloadingId = null; // allow preloading for the track after this one
isAudioPaused = false;
const store = usePlayerStore.getState();
@@ -482,11 +492,28 @@ function handleAudioError(message: string) {
* set of listeners before creating the second, avoiding duplicate handlers.
*/
export function initAudioListeners(): () => void {
// Dev-only: warn when audio:progress events arrive faster than 10/s.
// This would indicate the Rust emit interval was accidentally lowered.
let _devEventCount = 0;
let _devWindowStart = 0;
const pending = [
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) =>
handleAudioProgress(payload.current_time, payload.duration)
),
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => {
if (import.meta.env.DEV) {
_devEventCount++;
const now = Date.now();
if (_devWindowStart === 0) _devWindowStart = now;
if (now - _devWindowStart >= 1000) {
if (_devEventCount > 10) {
console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`);
}
_devEventCount = 0;
_devWindowStart = now;
}
}
handleAudioProgress(payload.current_time, payload.duration);
}),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
@@ -743,7 +770,7 @@ export const usePlayerStore = create<PlayerState>()(
isAudioPaused = false;
clearRadioReconnectTimer();
radioReconnectCount = 0;
gaplessPreloadingId = null;
gaplessPreloadingId = null; bytePreloadingId = null;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// Stop Rust engine in case a regular track was playing.
invoke('audio_stop').catch(() => {});
@@ -778,7 +805,7 @@ export const usePlayerStore = create<PlayerState>()(
const gen = ++playGeneration;
isAudioPaused = false;
gaplessPreloadingId = null; // new track — allow fresh preload for next
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// If a radio stream is active, stop it before the new track starts so
@@ -813,7 +840,7 @@ export const usePlayerStore = create<PlayerState>()(
setDeferHotCachePrefetch(true);
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
? (authState.replayGainMode === 'album' ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
invoke('audio_play', {
@@ -863,6 +890,10 @@ export const usePlayerStore = create<PlayerState>()(
set({ isPlaying: false });
},
resetAudioPause: () => {
isAudioPaused = false;
},
resume: () => {
if (get().currentRadio) {
radioAudio.play().catch(console.error);
@@ -891,7 +922,7 @@ export const usePlayerStore = create<PlayerState>()(
if (freshSong) set({ currentTrack: trackToPlay });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = authStateCold.replayGainEnabled
? (authStateCold.replayGainMode === 'album' ? trackToPlay.replayGainAlbumDb : trackToPlay.replayGainTrackDb) ?? null
? (authStateCold.replayGainMode === 'album' ? (trackToPlay.replayGainAlbumDb ?? trackToPlay.replayGainTrackDb) : trackToPlay.replayGainTrackDb) ?? null
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
@@ -1266,8 +1297,8 @@ export const usePlayerStore = create<PlayerState>()(
if (!currentTrack || !currentTrack.id) return;
const authState = useAuthStore.getState();
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album'
? currentTrack.replayGainAlbumDb
? (authState.replayGainMode === 'album'
? (currentTrack.replayGainAlbumDb ?? currentTrack.replayGainTrackDb)
: currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled
+133 -31
View File
@@ -21,17 +21,7 @@
inset: 0;
background-size: cover;
background-position: center;
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
transform: scale(1.05);
filter: blur(12px);
}
.hero:hover .hero-bg {
filter: blur(8px);
}
.hero:hover .hero-bg {
transform: scale(1);
transition: opacity 0.8s ease;
}
.hero-dots {
@@ -909,11 +899,8 @@
inset: 0;
background-size: cover;
background-position: center;
filter: blur(20px) brightness(0.6);
transform: scale(1.1);
/* Hide blur edges */
z-index: 0;
opacity: 0.8;
opacity: 0.35;
transition: opacity var(--transition-slow);
}
@@ -1148,6 +1135,8 @@
.album-detail-badge {
margin-bottom: 0.5rem;
background: var(--accent);
color: #fff;
}
.album-detail-back {
@@ -3014,23 +3003,22 @@
to { transform: translateY(0); opacity: 1; }
}
/* ── Animated dark mesh — GPU-only: only transform3d, no scale, real divs for will-change ── */
@keyframes mesh-aura-a {
0% { transform: translate3d(0, 0, 0); }
33% { transform: translate3d(5%, -6%, 0); }
66% { transform: translate3d(-3%, 2%, 0); }
100% { transform: translate3d(0, 0, 0); }
0% { transform: translate(0, 0 ); }
33% { transform: translate(5%, -6% ); }
66% { transform: translate(-3%, 2% ); }
100% { transform: translate(0, 0 ); }
}
@keyframes mesh-aura-b {
0% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(-6%, 5%, 0); }
100% { transform: translate3d(0, 0, 0); }
0% { transform: translate(0, 0 ); }
50% { transform: translate(-6%, 5% ); }
100% { transform: translate(0, 0 ); }
}
@keyframes portrait-drift {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -10px, 0); }
0%, 100% { transform: translateY(0 ); }
50% { transform: translateY(-10px); }
}
.fs-mesh-bg {
@@ -3042,12 +3030,11 @@
pointer-events: none;
}
/* Blobs are real divs so will-change: transform applies (pseudo-elements can't have it) */
/* Blobs are real divs — no will-change/filter to avoid software compositing layers */
.fs-mesh-blob {
position: absolute;
border-radius: 50%;
pointer-events: none;
will-change: transform;
}
.fs-mesh-blob-a {
@@ -3058,8 +3045,8 @@
bottom: -35%;
background: radial-gradient(ellipse,
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 86%) 0%,
transparent 65%);
filter: blur(55px);
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 96%) 45%,
transparent 70%);
animation: mesh-aura-a 26s ease-in-out infinite;
animation-delay: 350ms;
transition: background 400ms ease-in-out;
@@ -3072,8 +3059,8 @@
top: -25%;
background: radial-gradient(ellipse,
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 92%) 0%,
transparent 65%);
filter: blur(65px);
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 98%) 45%,
transparent 70%);
animation: mesh-aura-b 20s ease-in-out infinite;
animation-delay: 350ms;
transition: background 400ms ease-in-out;
@@ -4687,6 +4674,121 @@
padding: 40px 0;
}
/* ─ Radio NowPlaying section ─ */
.np-radio-section {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.np-badge-live {
background: rgba(239, 68, 68, 0.25);
color: #f87171;
display: inline-flex;
align-items: center;
}
.np-badge-azuracast {
background: rgba(99, 102, 241, 0.25);
color: #a5b4fc;
}
.np-radio-progress-wrap {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.np-radio-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
min-width: 32px;
}
.np-radio-progress-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.np-radio-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 1s linear;
}
.np-radio-next-track {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.np-radio-track-art {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.np-radio-track-art--sm {
width: 32px;
height: 32px;
}
.np-radio-track-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.np-radio-track-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.np-radio-track-artist {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Radio progress bar in PlayerBar */
.radio-progress-bar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.radio-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 1s linear;
}
/* Listener count in PlayerBar */
.player-radio-listeners {
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
}
/* Queue section */
.np-queue-section {
flex: 1;
+1
View File
@@ -1088,6 +1088,7 @@
flex-shrink: 0;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-card);
}
.player-album-art-wrap::after {
content: '';
+89 -25
View File
@@ -6516,11 +6516,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
/* Old Shire craftsmanship — vertical wood grain, warm hearthlight from right edge */
[data-theme='middle-earth'] .sidebar {
background-color: #221608;
background-image:
repeating-linear-gradient(90deg,
rgba(255, 220, 120, 0.04) 0px, rgba(255, 220, 120, 0.04) 1px,
transparent 1px, transparent 14px),
linear-gradient(180deg, #261a0a 0%, #1e1408 55%, #241c0e 100%);
background-image: linear-gradient(180deg, #261a0a 0%, #1e1408 55%, #241c0e 100%);
border-right: 2px solid rgba(200, 160, 40, 0.28);
box-shadow:
inset -3px 0 12px rgba(200, 140, 30, 0.10),
@@ -6982,6 +6978,40 @@ input[type="range"]:hover::-webkit-slider-thumb {
color: #7a5a30;
}
/* Queue: "Nächste Titel" divider label */
[data-theme='middle-earth'] .queue-divider span {
color: #a07840 !important;
}
/* Queue: artist text visible on hover for inactive items */
[data-theme='middle-earth'] .queue-item:hover .queue-item-artist,
[data-theme='middle-earth'] .queue-item.context-active .queue-item-artist {
color: #e8c878;
}
/* AlbumDetail header: artist name readable */
[data-theme='middle-earth'] .album-detail-artist,
[data-theme='middle-earth'] .album-detail-artist-link {
color: #e8c878;
}
[data-theme='middle-earth'] .album-detail-artist-link:hover {
color: #f8e060;
}
/* ArtistDetail About: heading, bio text + "Mehr lesen" readable */
[data-theme='middle-earth'] .np-card-title {
color: #6b4e2a;
}
[data-theme='middle-earth'] .np-bio-text {
color: #5a3e20;
}
[data-theme='middle-earth'] .np-bio-toggle {
color: #7a5228;
}
/*
Morpheus Matrix Style
*/
@@ -8281,19 +8311,19 @@ input[type="range"]:hover::-webkit-slider-thumb {
--ctp-teal: #00f0f0;
--ctp-yellow: #f0d000;
/* O-piece — yellow */
--ctp-mauve: #a020f0;
--ctp-mauve: #c070ff;
/* T-piece — purple */
--ctp-lavender: #a020f0;
--ctp-pink: #a020f0;
--ctp-lavender: #c070ff;
--ctp-pink: #c070ff;
--ctp-green: #00d000;
/* S-piece — green */
--ctp-red: #f02000;
/* Z-piece — red */
--ctp-flamingo: #f02000;
--ctp-maroon: #f02000;
--ctp-blue: #0060f0;
--ctp-blue: #4090ff;
/* J-piece — blue */
--ctp-sapphire: #0060f0;
--ctp-sapphire: #4090ff;
--ctp-peach: #f07800;
/* L-piece — orange */
--ctp-rosewater: #f07800;
@@ -8315,7 +8345,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
--text-primary: #ffffff;
--text-secondary: #f0d000;
/* O-piece yellow — like the score readout */
--text-muted: #3a3a6a;
--text-muted: #7878b8;
--border: #1e1e48;
--border-subtle: #0d0d22;
@@ -8343,7 +8373,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
[data-theme='tetrastack'] .player-bar {
border-top: 3px solid #00f0f0;
box-shadow:
0 -2px 0 #a020f0,
0 -2px 0 #c070ff,
0 -8px 24px rgba(0, 240, 240, 0.12);
}
@@ -8395,7 +8425,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
/* Album title in queue = T-piece purple */
[data-theme='tetrastack'] .queue-meta-album {
color: #a020f0;
color: #c070ff;
}
/* Active queue item = I-piece left border */
@@ -8473,8 +8503,8 @@ input[type="range"]:hover::-webkit-slider-thumb {
/* Artist pill links = J-piece blue */
[data-theme='tetrastack'] .artist-ext-link:hover {
background: rgba(0, 96, 240, 0.1);
color: #0060f0 !important;
border-color: rgba(0, 96, 240, 0.4) !important;
color: #4090ff !important;
border-color: rgba(64, 144, 255, 0.4) !important;
}
/* Now playing active track = Z-piece red accent */
@@ -8490,6 +8520,26 @@ input[type="range"]:hover::-webkit-slider-thumb {
color: #f0d000;
}
/* Sidebar section labels */
[data-theme='tetrastack'] .nav-section-label {
color: rgba(0, 240, 240, 0.45);
}
/* Queue tab buttons (Lyrics / Warteschlange) — inactive */
[data-theme='tetrastack'] .queue-tab-btn {
color: #6060a8;
}
/* Queue: inactive song artist */
[data-theme='tetrastack'] .queue-item-artist {
color: #6060a8;
}
/* Queue: "Nächste Titel" divider */
[data-theme='tetrastack'] .queue-divider span {
color: #6060a8 !important;
}
/* ── The Book (Social Media) ────────────────────────────────── */
[data-theme='the-book'] {
color-scheme: light;
@@ -12138,11 +12188,6 @@ input[type="range"]:hover::-webkit-slider-thumb {
/* Sidebar — iron plate lines + subtle red glow on active */
[data-theme='horde'] .sidebar {
background-color: #110300;
background-image: repeating-linear-gradient(180deg,
transparent 0px,
transparent 39px,
rgba(100, 20, 0, 0.18) 39px,
rgba(100, 20, 0, 0.18) 40px);
border-right: 1px solid rgba(140, 40, 10, 0.35);
}
@@ -12262,11 +12307,6 @@ input[type="range"]:hover::-webkit-slider-thumb {
/* Sidebar — cathedral stone columns with gold right-border */
[data-theme='alliance'] .sidebar {
background-color: #040c18;
background-image: repeating-linear-gradient(180deg,
transparent 0px,
transparent 47px,
rgba(60, 100, 180, 0.14) 47px,
rgba(60, 100, 180, 0.14) 48px);
border-right: 2px solid rgba(200, 170, 50, 0.30);
}
@@ -12846,6 +12886,30 @@ input[type="range"]:hover::-webkit-slider-thumb {
color: #6a5030;
}
/* Sidebar section labels (sky-blue bg needs light text) */
[data-theme='toy-tale'] .nav-section-label {
color: rgba(200, 230, 255, 0.55);
}
/* Queue tab buttons (Lyrics / Warteschlange) — inactive */
[data-theme='toy-tale'] .queue-tab-btn {
color: #b89060;
}
[data-theme='toy-tale'] .queue-tab-btn:hover {
color: #f0e8d8;
}
/* Queue: inactive song artist */
[data-theme='toy-tale'] .queue-item-artist {
color: #9a7848;
}
/* Queue: "Nächste Titel" divider */
[data-theme='toy-tale'] .queue-divider span {
color: #b89060 !important;
}
/* ─── North Park — South Park ─── */
/* Construction paper flat aesthetic paper-cream main, Colorado mountain-blue sidebar,
Kenny orange accent, South Park sky blue player accents. Light, no dark mode. */
+11
View File
@@ -0,0 +1,11 @@
import i18n from '../i18n';
/** Totals / statistics: localized "N hours M minutes" (not track mm:ss). */
export function formatHumanHoursMinutes(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) {
return i18n.t('common.durationHoursMinutes', { hours: h.toLocaleString(), minutes: m });
}
return i18n.t('common.durationMinutesOnly', { minutes: m });
}
+53
View File
@@ -0,0 +1,53 @@
/** Fields from Subsonic `ping` / any `subsonic-response` root (Navidrome sets type + serverVersion). */
export type SubsonicServerIdentity = {
type?: string;
serverVersion?: string;
openSubsonic?: boolean;
};
/** Result of `getRandomSongs` + `getSimilarSongs` probe (Instant Mix / agent chain). */
export type InstantMixProbeResult = 'ok' | 'empty' | 'error' | 'skipped';
const NAVIDROME_MIN_FOR_PLUGINS: [number, number, number] = [0, 60, 0];
function parseLeadingSemver(version: string | undefined): [number, number, number] | null {
if (!version) return null;
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
function semverGte(a: [number, number, number], b: [number, number, number]): boolean {
for (let i = 0; i < 3; i++) {
if (a[i] > b[i]) return true;
if (a[i] < b[i]) return false;
}
return true;
}
/**
* Navidrome version from ping supports the plugin system ( 0.60). Unknown `type` stays permissive
* until the first successful ping with metadata.
*/
export function isNavidromeAudiomuseSoftwareEligible(identity: SubsonicServerIdentity | undefined): boolean {
if (!identity?.type?.trim()) return true;
const t = identity.type.trim().toLowerCase();
if (t !== 'navidrome') return false;
const parsed = parseLeadingSemver(identity.serverVersion);
if (!parsed) return true;
return semverGte(parsed, NAVIDROME_MIN_FOR_PLUGINS);
}
/**
* Whether to show the per-server AudioMuse (Navidrome plugin) toggle in Settings.
* Uses software eligibility from ping plus an optional Instant Mix probe: if the server returns no
* similar tracks for several random songs, the row stays hidden (typical when no plugin / no agents).
*/
export function showAudiomuseNavidromeServerSetting(
identity: SubsonicServerIdentity | undefined,
instantMixProbe: InstantMixProbeResult | undefined,
): boolean {
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return false;
if (instantMixProbe === 'empty') return false;
return true;
}