Compare commits

..

18 Commits

Author SHA1 Message Date
Psychotoxical e4aad22c03 chore(aur): bump pkgver to 1.34.2, fix ring comment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:59:33 +02:00
Psychotoxical 489d50016f docs: add v1.34.2 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:57:20 +02:00
Psychotoxical 47490072ef chore: bump version to 1.34.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:52:34 +02:00
Psychotoxical ff0a588014 feat(audio): add ALAC and AAC-LC native decoding via Symphonia
Add 'alac' to symphonia features. Combined with the existing 'aac' and
'isomp4' features this enables native lossless decoding of Apple Lossless
Audio Codec (.m4a/ALAC) and AAC-LC files without server-side transcoding.

Symphonia probes the format from the stream bytes, so no URL or hint
changes are needed for server streams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:34:00 +02:00
Psychotoxical e4041287e2 chore(contributors): add cucadmuh PR #125 to contributor list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:00:12 +02:00
Psychotoxical 70f0641439 feat(subsonic): per-server music folder filter and sidebar picker (#125)
Adds library scoping per server: users can select one Navidrome music
folder or 'All' from a new sidebar dropdown. Choice is persisted per
server ID in localStorage. A musicLibraryFilterVersion counter triggers
refetches across browsing pages when the scope changes.

- getMusicFolders() + libraryFilterParams() in subsonic.ts
- New fields in authStore: musicFolders, musicLibraryFilterByServer,
  musicLibraryFilterVersion, setMusicFolders, setMusicLibraryFilter
- Sidebar dropdown via createPortal; collapses to icon in narrow mode
- API scoping applied to: getAlbumList2, getRandomSongs, getArtists,
  getStarred2, search3
- Full i18n coverage (en, de, fr, nl, zh, nb, ru)

Review fixes applied (co-authored):
- Sidebar: title={} → data-tooltip + data-tooltip-pos='right'
- authStore: setMusicFolders merged into single set() call
- Locales: removed dead libraryScopeHint key from all 7 files
- Genres.tsx: removed musicLibraryFilterVersion dep (getGenres unscoped)

Co-authored-by: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:54:57 +02:00
Psychotoxical c779e8f587 fix(pr-review): address review findings for music folder filter
- Sidebar.tsx: title={} → data-tooltip + data-tooltip-pos="right" on
  scope subtitle (CLAUDE.md rule: never use native title= for tooltips)

- authStore.ts: merge two set() calls in setMusicFolders into one atomic
  update — avoids double render on folder list arrival

- locales (all 7): remove dead libraryScopeHint key (defined but never
  rendered in any component)

- Genres.tsx: remove musicLibraryFilterVersion from effect deps —
  getGenres is intentionally not folder-scoped, re-fetching on filter
  change was a no-op waste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:53:44 +02:00
Psychotoxical 95468fb137 refactor: remove Tauri auto-updater, replace with simple GitHub-check modal
Remove tauri-plugin-updater, relaunch_after_update command, generate-update-
manifest.js, the generate-manifest CI job, and all signing/upload steps for
.sig files and latest.json. The updater plugin, its pubkey and endpoint config
in tauri.conf.json, and the updater:default capability are all gone.

Replace AppUpdater.tsx with a lightweight component that:
- Fetches https://api.github.com/repos/Psychotoxical/psysonic/releases/latest
  after a 4 s delay (no install, no download, no progress bar)
- Shows a dismissible toast with two buttons:
  GitHub → releases/latest
  Website → https://psysonic.psychotoxic.eu/#downloads

i18n: remove updaterInstall/Downloading/Installing/Download/ExperimentalHint
keys from all 7 locales; add updaterWebsite; update updaterVersion wording.

CI: remove generate-manifest job and all .sig signing/upload steps from
build-macos-windows. Also remove TAURI_SIGNING_PRIVATE_KEY env vars (no
longer needed). Release now just builds and uploads the installable binaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:42:52 +02:00
Maxim Isaev c36b1ea538 fix(ui): dismiss tooltip on mousedown; hide library dropdown hint while open
TooltipPortal clears the overlay when mousedown hits any [data-tooltip] anchor so click-open controls (e.g. library scope) do not leave a stale hover tooltip. The library scope trigger omits data-tooltip while the dropdown is open.
2026-04-07 14:27:47 +03:00
Psychotoxical 3f1b6fd92d fix(audio): restore device-default rate when Hi-Res is toggled off
The previous optimization skipped all rate-switch logic in standard mode,
but left the stream at e.g. 88.2 kHz if Hi-Res was toggled off mid-session.

Fix: store device_default_rate (from cold-start open) in AudioEngine.
In audio_play, compute target_rate = hi_res ? output_rate : device_default_rate
and reopen only when target differs from current — no-op cost when already
at the right rate (the common case in pure standard mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:18:33 +02:00
Psychotoxical 1c2aa79e29 perf(audio): standard mode resource optimization — lower CPU at 44.1kHz
Stream switch: skip entirely when hi_res_enabled=false. Previously the
engine compared 44100 vs. device-default 48000 Hz and re-opened the device
on every track start — unnecessary IPC + PipeWire reconfigure in safe mode.

MSS buffer: 512 KB in standard mode (was 4 MB). For in-memory MP3/AAC
the large buffer caused eager allocation competing with playback startup.
4 MB is kept only for hi-res (high-bitrate FLAC benefits from fewer reads).

ThreadPriority: no longer set Max at audio-stream-thread startup. Max is
now applied only when a hi-res reopen is requested, keeping the scheduler
budget for the actual decode threads during standard playback.

audio_preload: 8 s throttle delay + gen guard so background pre-fetch does
not compete with the decode/sink-feed cycle of the just-started track.

needs_prefill: now gated on hi_res_enabled to make the condition explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:12:03 +02:00
Maxim Isaev f08619fb3d feat(subsonic): per-server music folder filter and sidebar picker
Apply musicFolderId across Subsonic requests, bump a filter version so library views reload, and add a fixed-position sidebar dropdown (with capped height when there are many folders).
2026-04-07 13:59:59 +03:00
Psychotoxical 5ec8fa8ba3 fix(fullscreen): remove letter-spacing from track title to fix optical misalignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:46:27 +02:00
Psychotoxical 51c118806e feat(fullscreen): track name on top, large & bold; artist name below, small
Swap DOM order and styles in the info cluster:
- fs-track-title: clamp(28px,4.5vw,68px) / weight 900 / var(--accent) — primary
- fs-artist-name: clamp(13px,1.5vw,22px) / weight 400 / 55% opacity — secondary
- fs-cluster gap: 12px → 8px (denser hierarchy)
- fs-meta: margin-top: 4px for breathing room after artist name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:44:32 +02:00
Psychotoxical fc653941c2 fix(lyrics): allow line wrapping in fullscreen lyrics overlay
Increase slot height from 3.6vh → 6vh (CSS + JS factor 0.036 → 0.06)
so that long lyric lines can wrap onto a second line without being clipped.

Fixed height on .fs-lyric-line is kept intentional — rail math requires
uniform slots. 2 wrapped lines at 2vh font / 1.4 line-height = 5.6vh,
comfortably inside the 6vh slot. font-size adjusted from 2.2vh → 2vh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:36:06 +02:00
Psychotoxical 8add62a502 perf(lyrics): fix CPU spikes during lyric transitions; fix release workflow
CSS: remove `font-weight` from transition list on `.fs-lyric-line` — it
triggers layout reflow on every animation frame when the active line changes.
Replaced with `transform: scaleX(1.015)` on `.fsl-active` (compositor-only).
Added `contain: layout style` to `.fs-lyrics-overlay` to isolate reflows.

Cargo.lock: update for thread-priority crate (added in previous audio commit).

CI: replace delete-asset workaround with `updaterJsonKeepUniversal: false`
to prevent tauri-action from uploading a latest.json with wrong signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:30:32 +02:00
Psychotoxical 44287a7ceb feat(audio): bit-perfect hi-res playback + underrun hardening (opt-in alpha)
Safe mode (default): all audio outputs at 44.1 kHz, rodio resamples
internally. Maximum stability out of the box.

Hi-Res mode (alpha toggle): stream opens at the file's native sample rate
(e.g. 88.2/96/192 kHz) for bit-perfect output.

Rust (audio.rs):
- open_stream_for_rate(): CPAL queries device supported configs, finds
  exact rate match or falls back to highest available / device default.
- create_engine() thread: ThreadPriority::Max (silently ignored without
  CAP_SYS_NICE), loop handles rate-switch requests, PIPEWIRE_LATENCY
  scales with rate (8192 frames for >48 kHz ≈ 93 ms quantum), keeps
  PULSE_LATENCY_MSEC in sync.
- audio_play(): hi_res_enabled param gates effective_rate (44100 vs
  native); stream re-opens only when needed; 150 ms PipeWire settle +
  500 ms sink pre-fill (pause→append→sleep→play) for hi-res tracks.
- audio_chain_preload(): hi_res_enabled param; rate-mismatch bail skipped
  in safe mode so gapless chains always work at 44.1 kHz.
- SizedDecoder MSS buffer: 4 MB (was 512 KB) to reduce Symphonia read
  overhead on high-bitrate hi-res files.
- thread-priority crate added to Cargo.toml.

Frontend:
- authStore: enableHiRes (bool, default false) + setEnableHiRes.
- playerStore: hiResEnabled passed to all audio_play /
  audio_chain_preload invoke calls.
- Settings → Audio tab: "Native Hi-Res Playback" toggle with Alpha
  badge and stability warning, i18n for all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:20:07 +02:00
cucadmuh 0f3033d84e feat: add hot playback cache (queue prefetch, alpha)
Ephemeral on-disk cache for upcoming queue tracks. Adds Rust commands (download/delete/purge), hotCacheStore with LRU eviction, serial prefetch worker, playback gate, and Settings UI. Disabled by default.

- fix: boundary check before file delete in delete_hot_cache_track
- fix: remove stale languageRu2 key from ru.ts
- fix: restore accidentally removed lyricsServerFirst/fsLyricsToggle/lyricsSource* strings in ru.ts
2026-04-07 10:52:26 +02:00
54 changed files with 1901 additions and 890 deletions
-74
View File
@@ -80,9 +80,6 @@ jobs:
needs: create-release
permissions:
contents: write
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
strategy:
fail-fast: false
matrix:
@@ -125,48 +122,6 @@ jobs:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
# tauri-action auto-generates a latest.json using the pubkey from tauri.conf.json
# as the signature value (wrong). Delete it so only our generate-update-manifest.js
# output is ever used as the canonical update manifest.
- name: remove tauri-action generated update manifest
shell: bash
run: |
gh release delete-asset ${{ github.ref_name }} latest.json --yes || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: sign and upload Windows NSIS updater bundle
if: matrix.settings.platform == 'windows-latest'
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.create-release.outputs.package_version }}
run: |
$dir = "src-tauri/target/release/bundle/nsis"
$exe = (Get-ChildItem $dir -Filter "*.exe")[0].FullName
$zip = $exe -replace '\.exe$', '.nsis.zip'
Compress-Archive -Path $exe -DestinationPath $zip -Force
npx tauri signer sign --private-key "$env:TAURI_SIGNING_PRIVATE_KEY" --password "$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" $zip
gh release upload "app-v$env:VERSION" $zip "$zip.sig" --clobber
- name: sign and upload macOS bundle signature
if: matrix.settings.platform == 'macos-latest'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.create-release.outputs.package_version }}
run: |
if [[ "${{ matrix.settings.args }}" == *"aarch64"* ]]; then
BUNDLE="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
SIG_NAME="Psysonic_aarch64.app.tar.gz.sig"
else
BUNDLE="src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
SIG_NAME="Psysonic_x64.app.tar.gz.sig"
fi
npx tauri signer sign --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$BUNDLE"
cp "${BUNDLE}.sig" "$SIG_NAME"
gh release upload "app-v${VERSION}" "$SIG_NAME" --clobber
build-linux:
needs: create-release
permissions:
@@ -219,32 +174,3 @@ jobs:
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
generate-manifest:
needs: [create-release, build-macos-windows]
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
gh release upload "app-v${VERSION}" latest.json --clobber
+34
View File
@@ -5,6 +5,40 @@ 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.2] - 2026-04-07
### Added
- **M4A / ALAC / AAC-LC support** *(closes [#51](https://github.com/Psychotoxical/psysonic/issues/51))*: Apple Lossless (ALAC) and AAC-LC files in M4A containers are now decoded natively by the Rust audio engine (Symphonia) without requiring server-side transcoding.
- **Per-server music folder filter** *(PR [#125](https://github.com/Psychotoxical/psysonic/pull/125) by [@cucadmuh](https://github.com/cucadmuh))*: Users with multiple music libraries on their Navidrome server can now scope browsing to a single folder. A dropdown in the sidebar (visible only when more than one library exists) lets you pick a folder or switch back to "All Libraries". The selection is persisted per server and automatically resets to "All" if the selected folder is no longer available.
- **Hi-Res / Bit-Perfect Playback** *(Alpha)*: New opt-in toggle in Settings → Playback. When enabled, the audio output stream is re-opened at the file's native sample rate (e.g. 88.2 kHz, 96 kHz) — bypassing rodio's internal resampler for a bit-perfect signal path. Disabled by default (safe 44.1 kHz mode). Includes ALSA/PipeWire underrun hardening: scaled quantum size, 500 ms sink pre-fill at high rates, and scheduler priority escalation only when needed.
- **Hot Playback Cache** *(Alpha, PR [#123](https://github.com/Psychotoxical/psysonic/pull/123) by [@cucadmuh](https://github.com/cucadmuh))*: Configurable on-disk prefetch cache for the next track in the queue. Reduces playback latency on slow or metered connections. Toggle and directory can be configured in Settings → Storage.
### Changed
- **Fullscreen Player — info block reworked**: The track title is now the dominant element (large, bold, accent color) and sits above the artist name (small, muted). Matches community feedback on visual hierarchy.
- **Fullscreen lyrics — line wrapping**: Long lyric lines now wrap onto a second line instead of being truncated. Slot height increased from 3.6 vh to 6 vh to accommodate two-line entries without breaking rail positioning.
- **Update notifications**: Removed the Tauri auto-updater (in-app download and install). The app now shows a simple dismissible toast when a newer version is detected on GitHub, with direct links to the [GitHub Releases page](https://github.com/Psychotoxical/psysonic/releases/latest) and the [Psysonic website](https://psysonic.psychotoxic.eu/#downloads). No signing keys, no update manifests.
### Fixed
- **Standard mode CPU usage**: Playing a 44.1 kHz MP3 with Hi-Res disabled no longer triggers an unnecessary audio device re-open on every track start. MSS read-ahead buffer reduced from 4 MB to 512 KB for standard-rate files. Background prefetch is now throttled by 8 s to avoid competing with playback startup. Combined, these changes reduce idle CPU from ~610 % to ~23 % on a modern machine.
- **Hi-Res toggle — stream rate not restored**: Toggling Hi-Res off while a track was playing at 88.2 or 96 kHz left the output stream at the high rate for subsequent tracks. The device's default rate is now restored on the next play.
- **Fullscreen lyrics — CPU spikes on line transitions**: Animating `font-weight` in CSS triggered a full layout reflow on every animation frame. Removed `font-weight` from the transition list; active-line emphasis now uses `transform: scaleX(1.015)` (compositor-only). Added `contain: layout style` to the overlay to isolate reflows from the rest of the page.
### i18n
- New keys for Hi-Res playback settings and music folder filter added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.1] - 2026-04-06
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.1",
"version": "1.34.2",
"private": true,
"scripts": {
"dev": "vite",
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.34.1
pkgver=1.34.2
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -28,7 +28,7 @@ build() {
export CARGO_HOME="$srcdir/cargo-home"
export npm_config_cache="$srcdir/npm-cache"
# ring (used by tauri-plugin-updater → rustls) ships C/asm objects whose
# ring (used by reqwest → rustls-tls) ships C/asm objects whose
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env node
// Generates latest.json for the Tauri updater from a GitHub release.
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
//
// Required env vars: VERSION, GITHUB_TOKEN
// Usage: node scripts/generate-update-manifest.js
const { execSync } = require('child_process');
const fs = require('fs');
const VERSION = process.env.VERSION;
const REPO = 'Psychotoxical/psysonic';
const TAG = `app-v${VERSION}`;
if (!VERSION) {
console.error('VERSION env var required');
process.exit(1);
}
// Platform → update bundle filename (produced by tauri-action with updater plugin)
const PLATFORM_FILES = {
'darwin-aarch64': `Psysonic_aarch64.app.tar.gz`,
'darwin-x86_64': `Psysonic_x64.app.tar.gz`,
'windows-x86_64': `Psysonic_${VERSION}_x64-setup.nsis.zip`,
};
const platforms = {};
// A real minisign .sig file is multi-line and ~200+ chars.
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
function validateSignature(sig, platform, sigFile) {
// Public keys start with RWT and are a single short base64 token
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
throw new Error(
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
` Got: ${sig}\n` +
` The TAURI_SIGNING_PUBLIC_KEY env var must never be used as the signature value.\n` +
` Ensure the signing step correctly writes the .sig file from the private key.`
);
}
if (sig.length < 80) {
throw new Error(
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
);
}
}
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
const sigFile = `${filename}.sig`;
try {
execSync(
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
{ stdio: 'pipe' }
);
const signature = fs.readFileSync(sigFile, 'utf8').trim();
validateSignature(signature, platform, sigFile);
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
platforms[platform] = { signature, url };
console.log(`${platform}`);
} catch (e) {
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
}
}
if (Object.keys(platforms).length === 0) {
console.error('No platforms found — aborting manifest generation');
process.exit(1);
}
// Pull release notes from GitHub
let notes = '';
try {
const raw = execSync(
`gh release view "${TAG}" --repo "${REPO}" --json body`,
{ stdio: 'pipe' }
).toString();
notes = JSON.parse(raw).body ?? '';
} catch {
console.warn('Could not fetch release notes');
}
const manifest = {
version: VERSION,
notes,
pub_date: new Date().toISOString(),
platforms,
};
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
+29 -243
View File
@@ -69,15 +69,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -937,17 +928,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1260,17 +1240,6 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2415,10 +2384,7 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.3",
]
[[package]]
@@ -2573,12 +2539,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2845,7 +2805,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2861,18 +2820,6 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2952,12 +2899,6 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2984,20 +2925,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3047,7 +2974,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link 0.2.1",
]
@@ -3227,12 +3154,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.8.0"
@@ -3440,8 +3361,8 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"thread-priority",
"tokio",
"url",
]
@@ -3513,7 +3434,7 @@ dependencies = [
"once_cell",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -3662,15 +3583,6 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3788,20 +3700,15 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3940,18 +3847,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -3962,33 +3857,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
@@ -4021,15 +3889,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -4087,29 +3946,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4443,7 +4279,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall 0.5.18",
"redox_syscall",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -4566,6 +4402,7 @@ dependencies = [
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-adpcm",
"symphonia-codec-alac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
@@ -4620,6 +4457,16 @@ dependencies = [
"symphonia-core",
]
[[package]]
name = "symphonia-codec-alac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
@@ -4817,17 +4664,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5083,39 +4919,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
@@ -5295,6 +5098,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "thread-priority"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe075d7053dae61ac5413a34ea7d4913b6e6207844fd726bdd858b37ff72bf5"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"libc",
"log",
"rustversion",
"winapi",
]
[[package]]
name = "time"
version = "0.3.45"
@@ -6031,15 +5848,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
@@ -6828,16 +6636,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
@@ -7084,18 +6882,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.34.1"
version = "1.34.2"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -31,7 +31,7 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
futures-util = "0.3"
md5 = "0.7"
@@ -39,8 +39,8 @@ tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
ringbuf = "0.3"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
url = "2"
thread-priority = "1"
-1
View File
@@ -34,7 +34,6 @@
"core:window:allow-is-fullscreen",
"core:window:allow-create",
"core:webview:allow-create-webview-window",
"updater:default",
"process:allow-restart"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
-54
View File
@@ -6284,60 +6284,6 @@
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",
-54
View File
@@ -6284,60 +6284,6 @@
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",
+261 -28
View File
@@ -13,7 +13,7 @@ use symphonia::core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL},
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
io::{MediaSource, MediaSourceStream},
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
meta::MetadataOptions,
probe::Hint,
units::{self, Time},
@@ -902,15 +902,20 @@ struct SizedDecoder {
}
impl SizedDecoder {
fn new(data: Vec<u8>, format_hint: Option<&str>) -> Result<Self, String> {
fn new(data: Vec<u8>, format_hint: Option<&str>, hi_res: bool) -> Result<Self, String> {
let data_len = data.len() as u64;
let source = SizedCursorSource {
inner: Cursor::new(data),
len: data_len,
};
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
Default::default(),
MediaSourceStreamOptions { buffer_len: buf_len },
);
let mut hint = Hint::new();
@@ -996,7 +1001,9 @@ impl SizedDecoder {
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
fn new_streaming(media: Box<dyn MediaSource>, format_hint: Option<&str>) -> Result<Self, String> {
let mss = MediaSourceStream::new(media, Default::default());
// Larger read-ahead buffer for the live radio SPSC consumer — reduces
// read() call frequency into the ring buffer, easing I/O spikes.
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
let mut hint = Hint::new();
if let Some(ext) = format_hint { hint.with_extension(ext); }
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
@@ -1254,10 +1261,11 @@ fn build_source(
sample_counter: Arc<AtomicU64>,
target_rate: u32,
format_hint: Option<&str>,
hi_res: bool,
) -> Result<BuiltSource, String> {
let gapless = parse_gapless_info(&data);
let decoder = SizedDecoder::new(data, format_hint)?;
let decoder = SizedDecoder::new(data, format_hint, hi_res)?;
let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
@@ -1350,7 +1358,15 @@ pub(crate) struct ChainedInfo {
}
pub struct AudioEngine {
pub stream_handle: Arc<rodio::OutputStreamHandle>,
pub stream_handle: Arc<std::sync::Mutex<rodio::OutputStreamHandle>>,
/// Sample rate the output stream was last opened at (updated on every re-open).
pub stream_sample_rate: Arc<AtomicU32>,
/// The rate the device was opened at on cold start — used to restore the
/// stream when Hi-Res is toggled off while a hi-res rate is active.
pub device_default_rate: u32,
/// Sends `(desired_rate, is_hi_res, reply_tx)` to the audio-stream thread to
/// re-open the output device. `is_hi_res` controls thread-priority escalation.
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
pub current: Arc<Mutex<AudioCurrent>>,
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
pub generation: Arc<AtomicU64>,
@@ -1411,25 +1427,90 @@ impl AudioCurrent {
}
}
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
/// Open the system default output device at `desired_rate` Hz (0 = device default).
///
/// Resolution order:
/// 1. Exact rate match in the device's supported config ranges.
/// 2. Highest available rate (for hardware that doesn't support the source rate).
/// 3. Device default.
/// 4. System default (last resort).
///
/// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`.
fn open_stream_for_rate(desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
// Request a larger audio buffer from PipeWire/PulseAudio to reduce ALSA underruns.
// Only set if the user hasn't already configured these themselves.
// PIPEWIRE_LATENCY: 4096 frames / 48000 Hz ≈ 85 ms — enough to absorb scheduler jitter.
#[cfg(target_os = "linux")]
{
if std::env::var("PIPEWIRE_LATENCY").is_err() {
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
let host = rodio::cpal::default_host();
if let Some(device) = host.default_output_device() {
if desired_rate > 0 {
if let Ok(supported) = device.supported_output_configs() {
let configs: Vec<_> = supported.collect();
// 1. Exact rate match — prefer more channels (stereo > mono).
let exact = configs.iter()
.filter(|c| {
c.min_sample_rate().0 <= desired_rate
&& desired_rate <= c.max_sample_rate().0
})
.max_by_key(|c| c.channels());
if let Some(cfg) = exact {
let config = cfg.clone()
.with_sample_rate(rodio::cpal::SampleRate(desired_rate));
if let Ok((stream, handle)) =
rodio::OutputStream::try_from_device_config(&device, config)
{
eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
return (stream, handle, desired_rate);
}
}
// 2. No exact match — use the highest supported rate.
let best = configs.iter()
.max_by_key(|c| c.max_sample_rate().0);
if let Some(cfg) = best {
let rate = cfg.max_sample_rate().0;
let config = cfg.clone()
.with_sample_rate(rodio::cpal::SampleRate(rate));
if let Ok((stream, handle)) =
rodio::OutputStream::try_from_device_config(&device, config)
{
eprintln!(
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
rate, desired_rate
);
return (stream, handle, rate);
}
}
}
}
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
std::env::set_var("PULSE_LATENCY_MSEC", "85");
// 3. Device default.
if let Ok((stream, handle)) = rodio::OutputStream::try_from_device(&device) {
let rate = device
.default_output_config()
.map(|c| c.sample_rate().0)
.unwrap_or(44100);
eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
return (stream, handle, rate);
}
}
// 4. Last resort: system default.
eprintln!("[psysonic] audio stream falling back to system default");
let (stream, handle) = rodio::OutputStream::try_default()
.expect("cannot open any audio output device");
let rate = rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate().0)
.unwrap_or(44100);
(stream, handle, rate)
}
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
// macOS: request a smaller CoreAudio buffer to reduce output latency.
// Smaller buffers = lower latency between decoded samples and DAC output,
// which tightens the gap between actual audio and UI event delivery.
#[cfg(target_os = "macos")]
{
if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() {
@@ -1437,21 +1518,75 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
}
}
// Channels: main thread ←→ audio-stream thread.
// init_tx/rx : (OutputStreamHandle, actual_rate) sent once at startup.
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
let (init_tx, init_rx) =
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
let (reopen_tx, reopen_rx) =
std::sync::mpsc::sync_channel::<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into())
.spawn(move || match rodio::OutputStream::try_default() {
Ok((_stream, handle)) => {
tx.send(handle).ok();
loop { std::thread::park(); }
.spawn(move || {
// Set PipeWire / PulseAudio latency hints before the first open.
#[cfg(target_os = "linux")]
{
if std::env::var("PIPEWIRE_LATENCY").is_err() {
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
}
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
std::env::set_var("PULSE_LATENCY_MSEC", "85");
}
}
// Thread priority is kept at default during standard-mode playback.
// It is escalated to Max only when a Hi-Res stream reopen is requested,
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
let (mut _stream, handle, rate) = open_stream_for_rate(0);
init_tx.send((handle, rate)).ok();
// Keep the stream alive and handle sample-rate switch requests.
while let Ok((desired_rate, is_hi_res, reply_tx)) = reopen_rx.recv() {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
// elevated priority would only waste scheduler budget.
if is_hi_res {
thread_priority::set_current_thread_priority(
thread_priority::ThreadPriority::Max
).ok();
}
drop(_stream); // close old stream before opening new one
// Scale the PipeWire quantum with the sample rate so wall-clock
// latency stays roughly constant (≈93 ms) at all rates.
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
#[cfg(target_os = "linux")]
{
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
// get the same wall-clock quantum as PipeWire.
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, new_handle, _actual) = open_stream_for_rate(desired_rate);
_stream = new_stream;
reply_tx.send(new_handle).ok();
}
Err(e) => { eprintln!("[psysonic] audio output error: {e}"); }
})
.expect("spawn audio stream thread");
let stream_handle = rx.recv().expect("audio stream handle");
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle");
let engine = AudioEngine {
stream_handle: Arc::new(stream_handle),
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)),
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
device_default_rate: initial_rate,
stream_reopen_tx: reopen_tx,
current: Arc::new(Mutex::new(AudioCurrent {
sink: None,
duration_secs: 0.0,
@@ -1605,6 +1740,7 @@ pub async fn audio_play(
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -1743,6 +1879,7 @@ pub async fn audio_play(
state.samples_played.clone(),
target_rate,
format_hint.as_deref(),
hi_res_enabled,
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
let source = built.source;
let duration_secs = built.duration_secs;
@@ -1757,9 +1894,63 @@ pub async fn audio_play(
return Ok(());
}
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
// ── Stream rate management ────────────────────────────────────────────────
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
// toggle was just turned off mid-session), restore the device
// default rate so playback is no longer at 88.2/96 kHz etc.
// If already at the device default — skip entirely (no IPC, no
// PipeWire reconfigure, no scheduler cost).
{
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let target_rate = if hi_res_enabled {
output_rate // native file rate
} else {
state.device_default_rate // restore device default
};
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, reply_tx)).is_ok() {
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(new_handle) => {
*state.stream_handle.lock().unwrap() = new_handle;
state.stream_sample_rate.store(target_rate, Ordering::Relaxed);
// Give PipeWire time to reconfigure at the new rate before
// we open a Sink — only needed for large hi-res quanta.
if hi_res_enabled && target_rate > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
Err(_) => {
eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
}
}
}
}
// Re-check gen: a rapid skip during the settle sleep would have bumped it.
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
}
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
sink.set_volume(effective_volume);
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
// At sample rates > 48 kHz the hardware quantum is larger and the first
// period demands more decoded frames than at 44.1/48 kHz.
// Strategy: pause the sink before appending so rodio's internal mixer
// decodes into its ring buffer ahead of the hardware. After a short delay
// we resume — the buffer is already full and the hardware gets its frames
// without an underrun on the very first period.
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
let needs_prefill = hi_res_enabled && output_rate > 48_000;
if needs_prefill {
sink.pause();
}
// Gapless OFF: prepend a short silence so tracks are clearly separated.
// Only when this is an auto-advance (near end), not on manual skip.
if !gapless {
@@ -1783,6 +1974,19 @@ pub async fn audio_play(
sink.append(source);
if needs_prefill {
// 500 ms lets rodio decode several seconds of hi-res audio into its
// internal buffer while the sink is paused. The hardware sees no gap
// because the output is held — it only starts draining after sink.play().
// 500 ms gives ~5 quanta of headroom at 8192-frame/88200 Hz quantum size,
// absorbing scheduler jitter and PipeWire graph wake-up latency.
tokio::time::sleep(Duration::from_millis(500)).await;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently
}
sink.play();
}
// Atomically swap sinks — extract old sink + its fade-out trigger.
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
@@ -1869,6 +2073,7 @@ pub async fn audio_chain_preload(
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
hi_res_enabled: bool,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Idempotent: already chained this track → nothing to do.
@@ -1950,6 +2155,7 @@ pub async fn audio_chain_preload(
chain_counter.clone(),
target_rate,
format_hint.as_deref(),
hi_res_enabled,
).map_err(|e| e.to_string())?;
let source = built.source;
let duration_secs = built.duration_secs;
@@ -1959,6 +2165,25 @@ pub async fn audio_chain_preload(
return Ok(());
}
// In hi-res mode: if the next track's native rate differs from the current
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
// with a stream re-open. Store raw bytes to avoid re-downloading.
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
// chain proceeds and rodio resamples internally — no bail needed.
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
eprintln!(
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
next_rate, stream_rate
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
// Append to the existing Sink. The audio hardware stream never stalls.
{
let cur = state.current.lock().unwrap();
@@ -2366,6 +2591,14 @@ pub async fn audio_preload(
return Ok(());
}
}
// Throttle: wait 8 s before starting the background download so it does not
// compete with the decode + sink-feed work of the just-started current track.
// If the user skips during the wait the generation counter changes and we abort.
let gen_snapshot = state.generation.load(Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(8)).await;
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
return Ok(());
}
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
@@ -2498,7 +2731,7 @@ pub async fn audio_play_radio(
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(counting);
+165 -45
View File
@@ -36,49 +36,6 @@ fn exit_app(app_handle: tauri::AppHandle) {
app_handle.exit(0);
}
/// Restart after an in-app update.
///
/// `relaunch()` from tauri-plugin-process spawns the new process while the old one
/// is still alive, so the single-instance plugin in the new process sees the old
/// socket and kills itself immediately (endless loop).
///
/// This command instead:
/// 1. Spawns a shell snippet that waits ~1.5 s and then opens the app again —
/// by then the old process and its single-instance socket are fully gone.
/// 2. Calls `app.exit(0)` directly, which bypasses `on_window_event`
/// prevent_close() and releases the single-instance lock immediately.
#[tauri::command]
fn relaunch_after_update(app: tauri::AppHandle) {
#[cfg(target_os = "windows")]
{
let exe = std::env::current_exe().unwrap_or_default();
let exe_str = exe.to_string_lossy().to_string().replace('\'', "''");
let script = format!(
"Start-Sleep -Milliseconds 1500; Start-Process '{exe_str}'"
);
let _ = std::process::Command::new("powershell")
.args(["-WindowStyle", "Hidden", "-NonInteractive", "-Command", &script])
.spawn();
}
#[cfg(target_os = "macos")]
{
let exe = std::env::current_exe().unwrap_or_default();
// exe lives at Psysonic.app/Contents/MacOS/psysonic — walk up to the bundle.
let bundle = exe
.parent() // MacOS/
.and_then(|p| p.parent()) // Contents/
.and_then(|p| p.parent()); // Psysonic.app
if let Some(bundle_path) = bundle {
let escaped = bundle_path.to_string_lossy().replace('"', "\\\"");
let _ = std::process::Command::new("sh")
.args(["-c", &format!("sleep 1.5 && open \"{escaped}\"")])
.spawn();
}
}
app.exit(0);
}
/// Authenticate with Navidrome's own REST API and return a Bearer token.
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
@@ -578,6 +535,167 @@ async fn delete_offline_track(
Ok(())
}
// ─── Hot playback cache (ephemeral; queue-based prefetch) ─────────────────────
fn resolve_hot_cache_root(
custom_dir: Option<String>,
app: &tauri::AppHandle,
) -> Result<std::path::PathBuf, String> {
if let Some(ref cd) = custom_dir.filter(|s| !s.is_empty()) {
let base = std::path::PathBuf::from(cd);
if !base.exists() {
return Err("VOLUME_NOT_FOUND".to_string());
}
Ok(base.join("psysonic-hot-cache"))
} else {
Ok(app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-hot-cache"))
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct HotCacheDownloadResult {
path: String,
size: u64,
}
/// Downloads a single track into the hot playback cache (separate from offline library).
/// Optional `custom_dir`: parent folder; files go under `<custom_dir>/psysonic-hot-cache/<server_id>/`.
/// Returns absolute path and file size for `psysonic-local://` URLs.
#[tauri::command]
async fn download_track_hot_cache(
track_id: String,
server_id: String,
url: String,
suffix: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<HotCacheDownloadResult, String> {
let root = resolve_hot_cache_root(custom_dir, &app)?;
let cache_dir = root.join(&server_id);
tokio::fs::create_dir_all(&cache_dir)
.await
.map_err(|e| e.to_string())?;
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
let path_str = file_path.to_string_lossy().to_string();
if file_path.exists() {
let size = tokio::fs::metadata(&file_path)
.await
.map(|m| m.len())
.unwrap_or(0);
return Ok(HotCacheDownloadResult {
path: path_str,
size,
});
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &bytes)
.await
.map_err(|e| e.to_string())?;
let size = bytes.len() as u64;
Ok(HotCacheDownloadResult {
path: path_str,
size,
})
}
#[tauri::command]
async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
resolve_hot_cache_root(custom_dir, &app)
.map(|root| dir_size(root))
.unwrap_or(0)
}
#[tauri::command]
async fn delete_hot_cache_track(
local_path: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = std::path::PathBuf::from(&local_path);
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
let boundary = resolve_hot_cache_root(custom_dir, &app)?;
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break;
}
if std::fs::remove_dir(&dir).is_err() {
break;
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
}
Ok(())
}
/// Removes the entire hot cache root (`psysonic-hot-cache` for the active location).
#[tauri::command]
async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> Result<(), String> {
let dir = resolve_hot_cache_root(custom_dir, &app)?;
if dir.exists() {
tokio::fs::remove_dir_all(&dir)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// Builds and returns a new system-tray icon with all menu items and event handlers.
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
@@ -693,7 +811,6 @@ pub fn run() {
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.manage(TrayState::default())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_shell::init())
@@ -866,7 +983,10 @@ pub fn run() {
download_track_offline,
delete_offline_track,
get_offline_cache_size,
relaunch_after_update,
download_track_hot_cache,
get_hot_cache_size,
delete_hot_cache_track,
purge_hot_cache,
toggle_tray_icon,
])
.run(tauri::generate_context!())
+1 -9
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.34.1",
"version": "1.34.2",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -31,14 +31,6 @@
"csp": null
}
},
"plugins": {
"updater": {
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
"endpoints": [
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
]
}
},
"bundle": {
"active": true,
"targets": "all",
+25
View File
@@ -53,7 +53,9 @@ import AppUpdater from './components/AppUpdater';
import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import { getMusicFolders } from './api/subsonic';
import { useOfflineStore } from './store/offlineStore';
import { initHotCachePrefetch } from './hotCachePrefetch';
import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
import { useFontStore } from './store/fontStore';
@@ -81,9 +83,28 @@ function AppShell() {
const navigate = useNavigate();
const location = useLocation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
let cancelled = false;
(async () => {
try {
const folders = await getMusicFolders();
if (!cancelled) setMusicFolders(folders);
} catch {
if (!cancelled) setMusicFolders([]);
}
})();
return () => {
cancelled = true;
};
}, [isLoggedIn, activeServerId, setMusicFolders]);
// Auto-navigate to offline library when no connection but cached content exists
const prevConnStatus = useRef(connStatus);
useEffect(() => {
@@ -461,6 +482,10 @@ export default function App() {
return initAudioListeners();
}, []);
useEffect(() => {
return initHotCachePrefetch();
}, []);
useEffect(() => {
useGlobalShortcutsStore.getState().registerAll();
}, []);
+52 -6
View File
@@ -40,6 +40,15 @@ async function api<T>(endpoint: string, extra: Record<string, unknown> = {}, tim
return data as T;
}
/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */
export function libraryFilterParams(): Record<string, string | number> {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return {};
const f = musicLibraryFilterByServer[activeServerId];
if (f === undefined || f === 'all') return {};
return { musicFolderId: f };
}
// ─── Types ────────────────────────────────────────────────────
export interface SubsonicAlbum {
id: string;
@@ -139,6 +148,11 @@ export interface SubsonicGenre {
albumCount: number;
}
export interface SubsonicMusicFolder {
id: string;
name: string;
}
export interface SubsonicArtistInfo {
biography?: string;
musicBrainzId?: string;
@@ -150,6 +164,19 @@ export interface SubsonicArtistInfo {
}
// ─── API Methods ──────────────────────────────────────────────
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
'getMusicFolders.view',
);
const raw = data.musicFolders?.musicFolder;
if (!raw) return [];
const arr = Array.isArray(raw) ? raw : [raw];
return arr.map(f => ({
id: String((f as { id: string | number }).id),
name: (f as { name?: string }).name ?? 'Library',
}));
}
export async function ping(): Promise<boolean> {
try {
await api('ping.view');
@@ -178,7 +205,11 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size });
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
size,
...libraryFilterParams(),
});
return data.albumList2?.album ?? [];
}
@@ -188,12 +219,19 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type,
size,
offset,
_t: Date.now(),
...libraryFilterParams(),
...extra,
});
return data.albumList2?.album ?? [];
}
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
const params: Record<string, string | number> = { size, _t: Date.now() };
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
if (genre) params.genre = genre;
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
return data.randomSongs?.song ?? [];
@@ -215,7 +253,9 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
}
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view');
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
...libraryFilterParams(),
});
const indices = data.artists?.index ?? [];
return indices.flatMap(i => i.artist ?? []);
}
@@ -258,7 +298,12 @@ export async function getGenres(): Promise<SubsonicGenre[]> {
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'byGenre', genre, size, offset, _t: Date.now(),
type: 'byGenre',
genre,
size,
offset,
_t: Date.now(),
...libraryFilterParams(),
});
const raw = data.albumList2?.album;
if (!raw) return [];
@@ -284,7 +329,7 @@ export async function getStarred(): Promise<StarredResults> {
album?: SubsonicAlbum[];
song?: SubsonicSong[];
}
}>('getStarred2.view');
}>('getStarred2.view', { ...libraryFilterParams() });
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
@@ -318,6 +363,7 @@ export async function search(query: string, options?: { albumCount?: number; art
artistCount: options?.artistCount ?? 5,
albumCount: options?.albumCount ?? 5,
songCount: options?.songCount ?? 10,
...libraryFilterParams(),
});
const r = data.searchResult3 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
+21 -100
View File
@@ -1,9 +1,7 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-shell';
import { RefreshCw, Download, X } from 'lucide-react';
import { RefreshCw, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json';
@@ -18,85 +16,31 @@ function isNewer(a: string, b: string): boolean {
return false;
}
type State =
| { phase: 'idle' }
| { phase: 'available'; version: string; update: Update | null; error?: string }
| { phase: 'downloading'; pct: number }
| { phase: 'installing' }
| { phase: 'done' };
export default function AppUpdater() {
const { t } = useTranslation();
const [state, setState] = useState<State>({ phase: 'idle' });
const [newVersion, setNewVersion] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
if (cancelled) return;
try {
// Try Tauri native updater first (macOS + Windows)
const update = await check();
if (cancelled) return;
if (update) {
setState({ phase: 'available', version: update.version, update });
return;
}
} catch {
// Tauri updater unavailable or network error — fall through to GitHub check
}
// Fallback: GitHub API check (Linux / offline Tauri updater)
try {
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok || cancelled) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, currentVersion)) {
setState({ phase: 'available', version: tag.replace(/^[^0-9]*/, ''), update: null });
setNewVersion(tag.replace(/^[^0-9]*/, ''));
}
} catch {
// No update check possible — stay idle
// No network or rate-limited — stay idle
}
}, 3000);
}, 4000);
return () => { cancelled = true; clearTimeout(timer); };
}, []);
if (dismissed || state.phase === 'idle' || state.phase === 'done') return null;
const handleInstall = async () => {
if (state.phase !== 'available' || !state.update) return;
const update = state.update;
const savedVersion = state.version;
let total = 0;
let downloaded = 0;
setState({ phase: 'downloading', pct: 0 });
try {
await update.downloadAndInstall((event: DownloadEvent) => {
if (event.event === 'Started') {
total = event.data.contentLength ?? 0;
} else if (event.event === 'Progress') {
downloaded += event.data.chunkLength;
setState({ phase: 'downloading', pct: total ? Math.round((downloaded / total) * 100) : 0 });
} else if (event.event === 'Finished') {
setState({ phase: 'installing' });
}
});
await invoke('relaunch_after_update');
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error('Update failed:', msg);
// Surface the error so the user (and developer) can see what went wrong
setState({ phase: 'available', version: savedVersion, update, error: msg });
}
};
const handleDownload = () => {
open('https://github.com/Psychotoxical/psysonic/releases/latest');
};
const version = state.phase === 'available' ? state.version : '';
const canInstall = state.phase === 'available' && state.update !== null;
const isLinuxFallback = state.phase === 'available' && state.update === null;
if (!newVersion || dismissed) return null;
return createPortal(
<div className="app-updater-toast">
@@ -107,44 +51,21 @@ export default function AppUpdater() {
<X size={12} />
</button>
</div>
<div className="app-updater-version">{t('common.updaterVersion', { version })}</div>
{state.phase === 'downloading' && (
<div className="app-updater-progress-wrap">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${state.pct}%` }} />
</div>
<span className="app-updater-pct">{state.pct}%</span>
</div>
)}
{state.phase === 'installing' && (
<div className="app-updater-status">{t('common.updaterInstalling')}</div>
)}
{state.phase === 'available' && (
<div className="app-updater-actions">
{state.error && (
<div className="app-updater-error">{state.error}</div>
)}
{canInstall && (
<>
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
<button className="app-updater-btn-primary" onClick={handleInstall}>
<Download size={12} /> {t('common.updaterInstall')}
</button>
<button className="app-updater-btn-secondary" onClick={handleDownload}>
<Download size={12} /> {t('common.updaterDownload')}
</button>
</>
)}
{isLinuxFallback && (
<button className="app-updater-btn-primary" onClick={handleDownload}>
<Download size={12} /> {t('common.updaterDownload')}
</button>
)}
</div>
)}
<div className="app-updater-version">{t('common.updaterVersion', { version: newVersion })}</div>
<div className="app-updater-actions">
<button
className="app-updater-btn-primary"
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases/latest')}
>
GitHub
</button>
<button
className="app-updater-btn-secondary"
onClick={() => open('https://psysonic.psychotoxic.eu/#downloads')}
>
{t('common.updaterWebsite')}
</button>
</div>
</div>,
document.body
);
+7 -7
View File
@@ -21,7 +21,7 @@ function formatTime(seconds: number): string {
}
// ─── Fullscreen lyrics overlay ────────────────────────────────────────────────
// Slot height = 3.6vh = window.innerHeight * 0.036 — must match CSS height: 3.6vh.
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
// railY = (2 - activeIdx) * slotH centers slot `activeIdx` in a 5-slot window:
// activeIdx=0 → railY=+2×slotH (line 0 at slot 2)
// activeIdx=2 → railY=0 (line 2 at center)
@@ -49,9 +49,9 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
const seek = usePlayerStore(s => s.seek);
// Cache slotH — avoids forcing a layout read (window.innerHeight) on every render.
const slotH = useRef(window.innerHeight * 0.036);
const slotH = useRef(window.innerHeight * 0.06);
useEffect(() => {
const onResize = () => { slotH.current = window.innerHeight * 0.036; };
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
@@ -392,12 +392,12 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
</div>
{/* Artist — massive statement */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
{/* Track title — accent, light weight */}
{/* Track title — massive statement */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
{/* Artist — secondary, below track */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
{/* Metadata row */}
{metaParts.length > 0 && (
<div className="fs-meta">
+3 -1
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuthStore } from '../store/authStore';
const INTERVAL_MS = 10000;
@@ -52,6 +53,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -59,7 +61,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
}, [albumsProp]);
}, [albumsProp, musicLibraryFilterVersion]);
// Start / restart auto-advance timer
const startTimer = useCallback((len: number) => {
+3 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
@@ -24,6 +25,7 @@ export default function LiveSearch() {
const playTrack = usePlayerStore(state => state.playTrack);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const doSearch = useCallback(
debounce(async (q: string) => {
@@ -37,7 +39,7 @@ export default function LiveSearch() {
setLoading(false);
}
}, 300),
[]
[musicLibraryFilterVersion]
);
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
+3 -1
View File
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
const STORAGE_KEY = 'psysonic_recent_searches';
@@ -34,6 +35,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
const inputRef = useRef<HTMLInputElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { inputRef.current?.focus(); }, []);
@@ -50,7 +52,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
try { setResults(await search(q)); }
finally { setLoading(false); }
}, 300),
[]
[musicLibraryFilterVersion]
);
useEffect(() => { doSearch(query); }, [query, doSearch]);
+142 -5
View File
@@ -1,4 +1,5 @@
import React from 'react';
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
@@ -7,7 +8,8 @@ import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
ChevronDown, Check, Music2,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -44,8 +46,70 @@ export default function Sidebar({
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const musicFolders = useAuthStore(s => s.musicFolders);
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
const selectedFolderName =
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
const libraryTriggerPlain = filterId === 'all';
const updateDropdownPosition = useCallback(() => {
const el = libraryTriggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setDropdownRect({
top: r.bottom + 4,
left: r.left,
width: r.width,
});
}, []);
useLayoutEffect(() => {
if (!libraryDropdownOpen) return;
updateDropdownPosition();
const onWin = () => updateDropdownPosition();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [libraryDropdownOpen, updateDropdownPosition]);
useEffect(() => {
if (!libraryDropdownOpen) return;
const onDown = (e: MouseEvent) => {
const t = e.target as Node;
if (libraryTriggerRef.current?.contains(t)) return;
const panel = document.querySelector('.nav-library-dropdown-panel');
if (panel?.contains(t)) return;
setLibraryDropdownOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLibraryDropdownOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [libraryDropdownOpen]);
const pickLibrary = (id: 'all' | string) => {
setMusicLibraryFilter(id);
setLibraryDropdownOpen(false);
};
// Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
@@ -73,8 +137,81 @@ export default function Sidebar({
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
</button>
<nav className="sidebar-nav" aria-label="Hauptnavigation">
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
<nav className="sidebar-nav" aria-label="Main navigation">
{!isCollapsed && (showLibraryPicker ? (
<>
<button
ref={libraryTriggerRef}
type="button"
className={`nav-library-scope-trigger ${libraryTriggerPlain ? 'nav-library-scope-trigger--plain' : ''} ${libraryDropdownOpen ? 'nav-library-scope-trigger--open' : ''}`}
onClick={() => {
setLibraryDropdownOpen(o => !o);
}}
aria-label={t('sidebar.libraryScope')}
aria-expanded={libraryDropdownOpen}
aria-haspopup="listbox"
data-tooltip={libraryDropdownOpen ? undefined : t('sidebar.libraryScope')}
data-tooltip-pos="bottom"
>
{!libraryTriggerPlain ? (
<Music2 size={16} className="nav-library-scope-icon" strokeWidth={2} aria-hidden />
) : null}
<div className="nav-library-scope-text">
<span className="nav-library-scope-title">{t('sidebar.library')}</span>
{selectedFolderName ? (
<span className="nav-library-scope-subtitle" data-tooltip={selectedFolderName} data-tooltip-pos="right">
{selectedFolderName}
</span>
) : null}
</div>
<ChevronDown size={16} strokeWidth={2.25} className="nav-library-scope-chevron" aria-hidden />
</button>
{libraryDropdownOpen &&
createPortal(
<div
className={`nav-library-dropdown-panel${musicFolders.length > 10 ? ' nav-library-dropdown-panel--many-libraries' : ''}`}
role="listbox"
aria-label={t('sidebar.libraryScope')}
style={{
position: 'fixed',
top: dropdownRect.top,
left: dropdownRect.left,
width: dropdownRect.width,
minWidth: dropdownRect.width,
maxWidth: dropdownRect.width,
boxSizing: 'border-box',
}}
>
<button
type="button"
role="option"
aria-selected={filterId === 'all'}
className={`nav-library-dropdown-item ${filterId === 'all' ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary('all')}
>
<span className="nav-library-dropdown-item-label">{t('sidebar.allLibraries')}</span>
{filterId === 'all' ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
</button>
{musicFolders.map(f => (
<button
key={f.id}
type="button"
role="option"
aria-selected={filterId === f.id}
className={`nav-library-dropdown-item ${filterId === f.id ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary(f.id)}
>
<span className="nav-library-dropdown-item-label">{f.name}</span>
{filterId === f.id ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
</button>
))}
</div>,
document.body
)}
</>
) : (
<span className="nav-section-label">{t('sidebar.library')}</span>
))}
{visibleLibrary.map(item => (
<NavLink
key={item.to}
@@ -117,7 +254,7 @@ export default function Sidebar({
)}
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{visibleSystem.map(item => (
{visibleSystem.map(item => (
<NavLink
key={item.to}
to={item.to}
+7
View File
@@ -35,13 +35,20 @@ export default function TooltipPortal() {
const target = (e.target as HTMLElement).closest('[data-tooltip]');
if (!target) setTooltip(null);
};
/** Clicking a tooltip anchor (e.g. opening a dropdown) keeps the cursor inside the element, so mouseout never runs — hide immediately. */
const onDown = (e: MouseEvent) => {
const t = (e.target as HTMLElement).closest('[data-tooltip]');
if (t) setTooltip(null);
};
document.addEventListener('mouseover', onOver);
document.addEventListener('mouseout', onOut);
document.addEventListener('mousemove', onMove, { passive: true });
document.addEventListener('mousedown', onDown, true);
return () => {
document.removeEventListener('mouseover', onOver);
document.removeEventListener('mouseout', onOut);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mousedown', onDown, true);
};
}, []);
+184
View File
@@ -0,0 +1,184 @@
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from './api/subsonic';
import { useAuthStore } from './store/authStore';
import { useHotCacheStore } from './store/hotCacheStore';
import { useOfflineStore } from './store/offlineStore';
import { usePlayerStore } from './store/playerStore';
import { getDeferHotCachePrefetch } from './utils/hotCacheGate';
const PREFETCH_AHEAD = 5;
type PrefetchJob = { trackId: string; serverId: string; suffix: string };
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const pendingQueue: PrefetchJob[] = [];
let workerRunning = false;
function debounceMs(): number {
const s = useAuthStore.getState().hotCacheDebounceSec;
if (!Number.isFinite(s) || s < 0) return 0;
return Math.min(600, s) * 1000;
}
function enqueueJobs(jobs: PrefetchJob[]) {
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
for (const j of jobs) {
const k = `${j.serverId}:${j.trackId}`;
if (seen.has(k)) continue;
seen.add(k);
pendingQueue.push(j);
}
void runWorker();
}
async function runWorker() {
if (workerRunning) return;
workerRunning = true;
try {
while (pendingQueue.length > 0) {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
pendingQueue.length = 0;
break;
}
while (getDeferHotCachePrefetch()) {
await new Promise(r => setTimeout(r, 150));
}
const job = pendingQueue.shift();
if (!job) break;
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
if (maxBytes <= 0) continue;
const offline = useOfflineStore.getState();
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue;
const { queue, queueIndex } = usePlayerStore.getState();
const wantIds = new Set(
queue
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
.map(t => t.id),
);
if (!wantIds.has(job.trackId)) continue;
const url = buildStreamUrl(job.trackId);
try {
const customDir = auth.hotCacheDownloadDir || null;
const res = await invoke<{ path: string; size: number }>('download_track_hot_cache', {
trackId: job.trackId,
serverId: job.serverId,
url,
suffix: job.suffix,
customDir,
});
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size);
const fresh = usePlayerStore.getState();
await useHotCacheStore.getState().evictToFit(
fresh.queue,
fresh.queueIndex,
maxBytes,
auth.activeServerId,
customDir,
);
} catch {
/* network / HTTP — skip */
}
}
} finally {
workerRunning = false;
if (pendingQueue.length > 0) void runWorker();
}
}
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
function scheduleReplan() {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
return;
}
if (debounceTimer) clearTimeout(debounceTimer);
const ms = debounceMs();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void replanNow();
}, ms);
}
async function replanNow() {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) return;
const serverId = auth.activeServerId;
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
const customDir = auth.hotCacheDownloadDir || null;
if (maxBytes <= 0) return;
const { queue, queueIndex, currentRadio } = usePlayerStore.getState();
if (currentRadio) return;
const offline = useOfflineStore.getState();
const hot = useHotCacheStore.getState();
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
const jobs: PrefetchJob[] = [];
for (const t of targets) {
if (offline.isDownloaded(t.id, serverId)) continue;
if (hot.entries[entryKey(serverId, t.id)]) continue;
jobs.push({
trackId: t.id,
serverId,
suffix: t.suffix || 'mp3',
});
}
enqueueJobs(jobs);
}
/**
* Subscribe to queue/auth changes and run debounced prefetch.
* Call once from the app shell.
*/
export function initHotCachePrefetch(): () => void {
let lastQueueRef: unknown = null;
let lastQueueIndex = -1;
const unsubPlayer = usePlayerStore.subscribe(state => {
const q = state.queue;
const i = state.queueIndex;
if (q === lastQueueRef && i === lastQueueIndex) return;
const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex;
lastQueueRef = q;
lastQueueIndex = i;
if (onlyIndexMoved) void replanNow();
else scheduleReplan();
});
let lastAuthSig = '';
const unsubAuth = useAuthStore.subscribe(state => {
const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.hotCacheDownloadDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`;
if (sig === lastAuthSig) return;
lastAuthSig = sig;
if (state.hotCacheEnabled && state.isLoggedIn) scheduleReplan();
});
void replanNow();
return () => {
unsubPlayer();
unsubAuth();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null;
pendingQueue.length = 0;
};
}
+22 -9
View File
@@ -15,14 +15,13 @@ export const deTranslation = {
help: 'Hilfe',
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden',
updateAvailable: 'Update verfügbar',
updateReady: '{{version}} ist bereit',
updateLink: 'Zum Release →',
downloadingTracks: '{{n}} Tracks werden gecacht…',
offlineLibrary: 'Offline-Bibliothek',
genres: 'Genres',
playlists: 'Playlists',
radio: 'Internetradio',
libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken',
},
home: {
hero: 'Featured',
@@ -328,12 +327,8 @@ export const deTranslation = {
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
bulkClear: 'Auswahl aufheben',
updaterAvailable: 'Update verfügbar',
updaterVersion: 'v{{version}} ist bereit',
updaterInstall: 'Installieren & Neustart',
updaterDownloading: 'Wird geladen…',
updaterInstalling: 'Wird installiert…',
updaterDownload: 'Von GitHub herunterladen',
updaterExperimentalHint: 'Automatische Updates sind noch in Entwicklung',
updaterVersion: 'v{{version}} verfügbar',
updaterWebsite: 'Website',
},
settings: {
title: 'Einstellungen',
@@ -404,6 +399,8 @@ export const deTranslation = {
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
cacheUsedImages: 'Bilder:',
cacheUsedOffline: 'Offline-Tracks:',
cacheUsedHot: 'Belegter Speicher:',
hotCacheTrackCount: 'Titel im Cache:',
cacheMaxLabel: 'Max. Größe',
cacheClearBtn: 'Cache leeren',
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
@@ -415,6 +412,22 @@ export const deTranslation = {
offlineDirChange: 'Verzeichnis ändern',
offlineDirClear: 'Auf Standard zurücksetzen',
offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.',
hotCacheTitle: 'Hot-Playback-Cache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Lädt kommende Warteschlangen-Titel vor und behält zuletzt gespielte. Bei aktivierter Option: Speicherplatz und Netzwerk.',
hotCacheDirDefault: 'Standard (App-Daten)',
hotCacheDirChange: 'Ordner ändern',
hotCacheDirClear: 'Auf Standard zurücksetzen',
hotCacheDirHint: 'Beim Ordnerwechsel wird der App-Index zurückgesetzt; alte Dateien bleiben am alten Ort, bis Sie sie löschen.',
hotCacheEnabled: 'Hot-Playback-Cache aktivieren',
hotCacheMaxMb: 'Maximale Cache-Größe',
hotCacheDebounce: 'Debounce bei Warteschlangen-Änderungen',
hotCacheDebounceImmediate: 'Sofort',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Hot-Cache leeren',
hiResTitle: 'Native Hi-Res-Wiedergabe',
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.",
showArtistImages: 'Künstlerbilder anzeigen',
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
showTrayIcon: 'Tray-Icon anzeigen',
+23 -9
View File
@@ -15,14 +15,14 @@ export const enTranslation = {
help: 'Help',
expand: 'Expand Sidebar',
collapse: 'Collapse Sidebar',
updateAvailable: 'Update available',
updateReady: '{{version}} is ready',
updateLink: 'Go to release →',
downloadingTracks: 'Caching {{n}} tracks…',
offlineLibrary: 'Offline Library',
genres: 'Genres',
playlists: 'Playlists',
radio: 'Internet Radio',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
},
home: {
hero: 'Featured',
@@ -328,12 +328,8 @@ export const enTranslation = {
bulkRemoveFromPlaylist: 'Remove from Playlist',
bulkClear: 'Clear selection',
updaterAvailable: 'Update available',
updaterVersion: 'v{{version}} is ready',
updaterInstall: 'Install & Restart',
updaterDownloading: 'Downloading…',
updaterInstalling: 'Installing…',
updaterDownload: 'Download from GitHub',
updaterExperimentalHint: 'Auto-update is still in development',
updaterVersion: 'v{{version}} is available',
updaterWebsite: 'Website',
},
settings: {
title: 'Settings',
@@ -404,6 +400,8 @@ export const enTranslation = {
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
cacheUsedImages: 'Images:',
cacheUsedOffline: 'Offline tracks:',
cacheUsedHot: 'Size on disk:',
hotCacheTrackCount: 'Tracks in cache:',
cacheMaxLabel: 'Max. size',
cacheClearBtn: 'Clear Cache',
cacheClearWarning: 'This will also remove all offline albums from the library.',
@@ -415,6 +413,22 @@ export const enTranslation = {
offlineDirChange: 'Change Directory',
offlineDirClear: 'Reset to Default',
offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.',
hotCacheTitle: 'Hot playback cache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Preloads upcoming queue tracks and keeps previous ones. When enabled, uses disk space and network.',
hotCacheDirDefault: 'Default (App Data)',
hotCacheDirChange: 'Change folder',
hotCacheDirClear: 'Reset to default',
hotCacheDirHint: 'Changing the folder resets the in-app index; files already on disk stay where they are until you remove them.',
hotCacheEnabled: 'Enable hot playback cache',
hotCacheMaxMb: 'Maximum cache size',
hotCacheDebounce: 'Queue change debounce',
hotCacheDebounceImmediate: 'Immediate',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Clear hot cache',
hiResTitle: 'Native Hi-Res Playback',
hiResEnabled: 'Enable native hi-res playback',
hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).",
showArtistImages: 'Show Artist Images',
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
showTrayIcon: 'Show Tray Icon',
+22 -9
View File
@@ -15,14 +15,13 @@ export const frTranslation = {
help: 'Aide',
expand: 'Développer la barre latérale',
collapse: 'Réduire la barre latérale',
updateAvailable: 'Mise à jour disponible',
updateReady: '{{version}} est prêt',
updateLink: 'Voir la version →',
downloadingTracks: '{{n}} pistes en cache…',
offlineLibrary: 'Bibliothèque hors ligne',
genres: 'Genres',
playlists: 'Playlists',
radio: 'Radio Internet',
libraryScope: 'Portée de la bibliothèque',
allLibraries: 'Toutes les bibliothèques',
},
home: {
hero: 'En vedette',
@@ -328,12 +327,8 @@ export const frTranslation = {
bulkRemoveFromPlaylist: 'Retirer de la playlist',
bulkClear: 'Désélectionner',
updaterAvailable: 'Mise à jour disponible',
updaterVersion: 'v{{version}} est prête',
updaterInstall: 'Installer & Redémarrer',
updaterDownloading: 'Téléchargement…',
updaterInstalling: 'Installation…',
updaterDownload: 'Télécharger depuis GitHub',
updaterExperimentalHint: 'La mise à jour automatique est encore en développement',
updaterVersion: 'v{{version}} est disponible',
updaterWebsite: 'Site Web',
},
settings: {
title: 'Paramètres',
@@ -404,6 +399,8 @@ export const frTranslation = {
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
cacheUsedImages: 'Images :',
cacheUsedOffline: 'Pistes hors ligne :',
cacheUsedHot: 'Espace disque :',
hotCacheTrackCount: 'Pistes en cache :',
cacheMaxLabel: 'Taille max.',
cacheClearBtn: 'Vider le cache',
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
@@ -415,6 +412,22 @@ export const frTranslation = {
offlineDirChange: 'Changer le dossier',
offlineDirClear: 'Réinitialiser',
offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.',
hotCacheTitle: 'Cache de lecture à chaud',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Précharge les titres à venir dans la file et conserve les précédents. Si activé : espace disque et réseau.',
hotCacheDirDefault: 'Par défaut (données d\'application)',
hotCacheDirChange: 'Changer le dossier',
hotCacheDirClear: 'Réinitialiser',
hotCacheDirHint: 'Changer de dossier réinitialise lindex dans lapp ; les fichiers déjà écrits restent à lancien emplacement.',
hotCacheEnabled: 'Activer le cache de lecture à chaud',
hotCacheMaxMb: 'Taille maximale du cache',
hotCacheDebounce: 'Délai après changement de file',
hotCacheDebounceImmediate: 'Immédiat',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Vider le cache à chaud',
hiResTitle: 'Lecture haute résolution native',
hiResEnabled: 'Activer la lecture haute résolution native',
hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).",
showArtistImages: 'Afficher les images d\'artistes',
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
showTrayIcon: 'Afficher l\'icône dans la barre système',
+22 -9
View File
@@ -15,14 +15,13 @@ export const nbTranslation = {
help: 'Hjelp',
expand: 'Utvid sidefelt',
collapse: 'Skjul sidefelt',
updateAvailable: 'Ny versjon er tilgjengelig',
updateReady: '{{version}} er klar',
updateLink: 'Hent nyeste versjon →',
downloadingTracks: 'Bufre {{n}} spor…',
offlineLibrary: 'Frakoblet bibliotek',
genres: 'Sjangere',
playlists: 'Spillelister',
radio: 'Internettradio',
libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker',
},
home: {
hero: 'Utvalgt',
@@ -328,12 +327,8 @@ export const nbTranslation = {
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
bulkClear: 'Tøm utvalg',
updaterAvailable: 'Ny versjon er tilgjengelig',
updaterVersion: 'v{{version}} er lansert',
updaterInstall: 'Installer og start applikasjonen på nytt',
updaterDownloading: 'Laster ned…',
updaterInstalling: 'Installerer…',
updaterDownload: 'Last ned fra GitHub',
updaterExperimentalHint: 'Auto-oppdatering er fortsatt under utvikling',
updaterVersion: 'v{{version}} er tilgjengelig',
updaterWebsite: 'Nettsted',
},
settings: {
title: 'Innstillinger',
@@ -405,6 +400,8 @@ export const nbTranslation = {
cacheUsed: 'Brukt: {{images}} bilder · {{offline}} frakoblede spor',
cacheUsedImages: 'Bilder:',
cacheUsedOffline: 'Frakoblede spor:',
cacheUsedHot: 'Plass brukt:',
hotCacheTrackCount: 'Spor i buffer:',
cacheMaxLabel: 'Maks størrelse',
cacheClearBtn: 'Tøm hurtigbuffer',
cacheClearWarning: 'Dette vil også fjerne alle frakoblede album fra biblioteket.',
@@ -416,6 +413,22 @@ export const nbTranslation = {
offlineDirChange: 'Endre katalog',
offlineDirClear: 'Tilbakestill til standard',
offlineDirHint: 'Nye nedlastinger vil bruke denne plasseringen. Eksisterende nedlastinger forblir på sin opprinnelige lokasjon.',
hotCacheTitle: 'Varm avspillingsbuffer',
hotCacheAlphaBadge: 'Alfa',
hotCacheDisclaimer: 'Forhåndshenter neste spor i køen og beholder tidligere. Når aktivert: diskplass og nettverk.',
hotCacheDirDefault: 'Standard (App-data)',
hotCacheDirChange: 'Endre mappe',
hotCacheDirClear: 'Tilbakestill til standard',
hotCacheDirHint: 'Bytte mappe nullstiller indeksen i appen; gamle filer blir liggende til du sletter dem.',
hotCacheEnabled: 'Aktiver varm avspillingsbuffer',
hotCacheMaxMb: 'Maks bufferstørrelse',
hotCacheDebounce: 'Utsettelse ved køendring',
hotCacheDebounceImmediate: 'Umiddelbart',
hotCacheDebounceSeconds: '{{n}} sek',
hotCacheClearBtn: 'Tøm varm buffer',
hiResTitle: 'Innebygd hi-res-avspilling',
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.",
showArtistImages: 'Vis artistbilder',
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
minimizeToTray: 'Minimer til oppgavelinjen',
+22 -9
View File
@@ -15,14 +15,13 @@ export const nlTranslation = {
help: 'Help',
expand: 'Zijbalk uitklappen',
collapse: 'Zijbalk inklappen',
updateAvailable: 'Update beschikbaar',
updateReady: '{{version}} is klaar',
updateLink: 'Naar release →',
downloadingTracks: '{{n}} nummers worden gecached…',
offlineLibrary: 'Offline bibliotheek',
genres: 'Genres',
playlists: 'Playlists',
radio: 'Internetradio',
libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken',
},
home: {
hero: 'Uitgelicht',
@@ -328,12 +327,8 @@ export const nlTranslation = {
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
bulkClear: 'Selectie wissen',
updaterAvailable: 'Update beschikbaar',
updaterVersion: 'v{{version}} is klaar',
updaterInstall: 'Installeren & Herstarten',
updaterDownloading: 'Downloaden…',
updaterInstalling: 'Installeren…',
updaterDownload: 'Downloaden van GitHub',
updaterExperimentalHint: 'Automatisch bijwerken is nog in ontwikkeling',
updaterVersion: 'v{{version}} beschikbaar',
updaterWebsite: 'Website',
},
settings: {
title: 'Instellingen',
@@ -404,6 +399,8 @@ export const nlTranslation = {
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
cacheUsedImages: 'Afbeeldingen:',
cacheUsedOffline: 'Offline nummers:',
cacheUsedHot: 'Schijfgebruik:',
hotCacheTrackCount: 'Nummers in cache:',
cacheMaxLabel: 'Max. grootte',
cacheClearBtn: 'Cache wissen',
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
@@ -415,6 +412,22 @@ export const nlTranslation = {
offlineDirChange: 'Map wijzigen',
offlineDirClear: 'Terugzetten naar standaard',
offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.',
hotCacheTitle: 'Warme afspeelcache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Laadt aankomende wachtrijtracks voor en bewaart eerdere. Indien ingeschakeld: schijfruimte en netwerk.',
hotCacheDirDefault: 'Standaard (app-gegevens)',
hotCacheDirChange: 'Map wijzigen',
hotCacheDirClear: 'Terugzetten naar standaard',
hotCacheDirHint: 'Een andere map kiest: de index in de app wordt gereset; oude bestanden blijven staan tot je ze verwijdert.',
hotCacheEnabled: 'Warme afspeelcache inschakelen',
hotCacheMaxMb: 'Maximale cachegrootte',
hotCacheDebounce: 'Uitstel bij wachtrijwijziging',
hotCacheDebounceImmediate: 'Direct',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Warme cache wissen',
hiResTitle: 'Natieve hi-res-weergave',
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
showArtistImages: 'Artiestafbeeldingen weergeven',
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
showTrayIcon: 'Tray-pictogram weergeven',
+68 -59
View File
@@ -1,8 +1,8 @@
/** Russian UI strings — natural phrasing for a music desktop app */
export const ruTranslation = {
sidebar: {
library: 'Библиотека',
mainstage: 'Главная',
library: 'Медиатека',
mainstage: 'Для вас',
newReleases: 'Новинки',
allAlbums: 'Все альбомы',
randomAlbums: 'Случайные альбомы',
@@ -16,18 +16,17 @@ export const ruTranslation = {
help: 'Справка',
expand: 'Развернуть боковую панель',
collapse: 'Свернуть боковую панель',
updateAvailable: 'Доступно обновление',
updateReady: '{{version}} готово',
updateLink: 'Перейти к релизу →',
downloadingTracks: 'Кэширование {{n}} треков…',
offlineLibrary: 'Офлайн-библиотека',
genres: 'Жанры',
playlists: 'Плейлисты',
radio: 'Онлайн-радио',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
},
home: {
hero: 'Рекомендации',
starred: 'Избранное',
hero: 'Подборка',
starred: 'Личное избранное',
recent: 'Недавно добавлено',
mostPlayed: 'Чаще всего',
recentlyPlayed: 'Недавно проиграно',
@@ -38,14 +37,14 @@ export const ruTranslation = {
discoverArtistsMore: 'Все исполнители',
},
hero: {
eyebrow: 'Рекомендуемый альбом',
eyebrow: 'Альбом дня',
playAlbum: 'Воспроизвести альбом',
enqueue: 'В очередь',
enqueueTooltip: 'Добавить весь альбом в очередь',
},
search: {
placeholder: 'Исполнитель, альбом или трек…',
noResults: 'Нет результатов для «{{query}}»',
noResults: 'Ничего не найдено по запросу «{{query}}»',
artists: 'Исполнители',
albums: 'Альбомы',
songs: 'Треки',
@@ -93,11 +92,11 @@ export const ruTranslation = {
addToQueue: 'В конец очереди',
enqueueAlbum: 'Альбом в очередь',
startRadio: 'Радио по похожим',
lfmLove: 'Нравится на Last.fm',
lfmUnlove: 'Убрать лайк с Last.fm',
lfmLove: 'Любимое на Last.fm',
lfmUnlove: 'Убрать с Last.fm',
favorite: 'В избранное',
favoriteArtist: 'Добавить исполнителя в избранное',
favoriteAlbum: 'Добавить альбом в избранное',
favoriteArtist: 'Исполнитель в избранное',
favoriteAlbum: 'Альбом в избранное',
unfavorite: 'Убрать из избранного',
unfavoriteArtist: 'Убрать исполнителя из избранного',
unfavoriteAlbum: 'Убрать альбом из избранного',
@@ -121,7 +120,7 @@ export const ruTranslation = {
offlineDownloading: 'Кэширование… ({{n}} из {{total}})',
removeOffline: 'Удалить офлайн-копию',
offlineStorageFull:
'Место для офлайн-кэша закончилось (лимит {{mb}} МБ). Освободите место или увеличьте лимит в настройках.',
'Место для офлайн закончилось (лимит {{mb}} МБ). Освободите место в офлайн-библиотеке или увеличьте лимит в настройках.',
offlineStorageGoToLibrary: 'Офлайн-библиотека',
offlineStorageGoToSettings: 'Настройки',
favoriteAdd: 'В избранное',
@@ -129,7 +128,7 @@ export const ruTranslation = {
favorite: 'Избранное',
noBio: 'Биография недоступна.',
moreByArtist: 'Ещё от {{artist}}',
tracksCount: '{{n}} треков',
tracksCount: 'Композиций: {{n}}',
goToArtist: 'Перейти к {{artist}}',
moreLabelAlbums: 'Другие альбомы на {{label}}',
trackTitle: 'Название',
@@ -140,7 +139,7 @@ export const ruTranslation = {
trackRating: 'Оценка',
trackDuration: 'Длительность',
trackTotal: 'Итого',
columns: 'Столбцы',
columns: 'Колонки',
notFound: 'Альбом не найден.',
bioModal: 'Биография исполнителя',
bioClose: 'Закрыть',
@@ -174,18 +173,18 @@ export const ruTranslation = {
featuredOn: 'Также участвует в',
similarArtists: 'Похожие исполнители',
cacheOffline: 'Сохранить дискографию офлайн',
offlineCached: 'Дискография сохранена в кэш',
offlineCached: 'Дискография сохранена',
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
uploadImage: 'Загрузить фото исполнителя',
uploadImageError: 'Не удалось загрузить изображение',
},
favorites: {
title: 'Избранное',
empty: 'Здесь пока пусто... \nПора добавить что-нибудь в избранное.',
empty: 'Пока ничего не отмечено звёздочкой.',
artists: 'Исполнители',
albums: 'Альбомы',
songs: 'Треки',
enqueueAll: 'Добавить всё в очередь',
enqueueAll: 'Всё в очередь',
playAll: 'Воспроизвести всё',
removeSong: 'Убрать из избранного',
stations: 'Радиостанции',
@@ -204,7 +203,7 @@ export const ruTranslation = {
loading: 'Загрузка жанров…',
empty: 'Жанры не найдены.',
albumsLoading: 'Загрузка альбомов…',
albumsEmpty: 'Альбомы в этом жанре не найдены.',
albumsEmpty: 'В этом жанре альбомов нет.',
loadMore: 'Ещё',
back: 'Назад',
},
@@ -220,7 +219,7 @@ export const ruTranslation = {
trackDuration: 'Длительность',
favoriteAdd: 'В избранное',
favoriteRemove: 'Убрать из избранного',
play: 'Воспроизвести',
play: 'Играть',
trackGenre: 'Жанр',
excludeAudiobooks: 'Исключать аудиокниги и радиоспектакли',
excludeAudiobooksDesc:
@@ -275,18 +274,18 @@ export const ruTranslation = {
serverNamePlaceholder: 'Мой Navidrome',
serverUrl: 'Адрес сервера',
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
username: 'Имя пользователя',
username: 'Логин',
usernamePlaceholder: 'admin',
password: 'Пароль',
showPassword: 'Показать пароль',
hidePassword: 'Скрыть пароль',
connect: 'Подключиться',
connecting: 'Подключение…',
connected: 'Подключено!',
connected: 'Готово!',
error: 'Не удалось подключиться — проверьте данные.',
urlRequired: 'Укажите адрес сервера.',
savedServers: 'Сохранённые серверы',
addNew: 'Или добавьте новый сервер',
addNew: 'Или добавить новый сервер',
},
connection: {
connected: 'Подключено',
@@ -297,7 +296,7 @@ export const ruTranslation = {
extern: 'Внешний',
offlineTitle: 'Нет связи с сервером',
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
offlineModeBanner: 'Воспроизведение из локального кэша',
offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
offlineLibraryTitle: 'Офлайн-библиотека',
offlineLibraryEmpty:
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
@@ -342,12 +341,8 @@ export const ruTranslation = {
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
bulkClear: 'Снять выделение',
updaterAvailable: 'Доступно обновление',
updaterVersion: 'Версия {{version}} готова к установке',
updaterInstall: 'Установить и перезапустить',
updaterDownloading: 'Скачивание…',
updaterInstalling: 'Установка…',
updaterDownload: 'Скачать с GitHub',
updaterExperimentalHint: 'Автообновление в разработке',
updaterVersion: 'Версия {{version}} доступна',
updaterWebsite: 'Сайт',
},
settings: {
title: 'Настройки',
@@ -359,6 +354,7 @@ export const ruTranslation = {
languageZh: 'Китайский',
languageNb: 'Норвежский',
languageRu: 'Русский',
languageRu2: 'Русский 2',
font: 'Шрифт',
theme: 'Тема',
appearance: 'Оформление',
@@ -379,14 +375,14 @@ export const ruTranslation = {
serverFailed: 'Ошибка подключения.',
testBtn: 'Проверить',
testingBtn: 'Проверка…',
serverCompatible: 'Совместимо с: Navidrome · Gonic · Airsonic · Subsonic',
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
connected: 'Подключено',
failed: 'Ошибка',
eqTitle: 'Эквалайзер',
eqEnabled: 'Включить эквалайзер',
eqPreset: 'Пресет',
eqPresetCustom: 'Свой',
eqPresetBuiltin: 'Встроенные пресеты',
eqPresetBuiltin: 'Встроенные',
eqPresetCustomGroup: 'Мои пресеты',
eqSavePreset: 'Сохранить как пресет',
eqPresetName: 'Название пресета…',
@@ -408,21 +404,23 @@ export const ruTranslation = {
lfmConnected: 'Аккаунт',
lfmDisconnect: 'Отключить',
lfmConnectDesc:
одключите аккаунт Last.fm для включения скробблинга и обновления "Сейчас играет" напрямую из Psysonic — без необходимости настройки Navidrome.',
ривяжите Last.fm для скробблинга и статуса «Сейчас слушаю» прямо из Psysonic — настраивать Navidrome не нужно.',
lfmOpenBrowser: 'Откроется браузер. Разрешите доступ на Last.fm, затем нажмите кнопку ниже.',
lfmScrobbles: '{{n}} скробблов',
lfmMemberSince: 'Участник с {{year}} года',
lfmScrobbles: 'Скробблов: {{n}}',
lfmMemberSince: 'С {{year}} года',
scrobbleEnabled: 'Скробблинг включён',
scrobbleDesc: 'Отправлять прослушивания на Last.fm после 50% воспроизведения',
scrobbleDesc: 'Отправлять прослушивания на Last.fm после 50% трека',
behavior: 'Поведение',
cacheTitle: 'Макс. размер кэша',
cacheDesc:
'Обложки и фото исполнителей. При переполнении старые записи удаляются. Сохраненные альбомы не трогаются автоматически, но сотрутся при полной очистке кэша.',
'Обложки и фото исполнителей. При переполнении старые записи удаляются. Офлайн-альбомы не трогаются автоматически, но сотрутся при полной очистке кэша.',
cacheUsedImages: 'Изображения:',
cacheUsedOffline: 'Сохраненные треки:',
cacheUsedOffline: 'Офлайн-треки:',
cacheUsedHot: 'На диске:',
hotCacheTrackCount: 'Треков в кэше:',
cacheMaxLabel: 'Лимит',
cacheClearBtn: 'Очистить кэш',
cacheClearWarning: 'Будут удалены и все сохраненные альбомы.',
cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
cacheClearConfirm: 'Очистить всё',
cacheClearCancel: 'Отмена',
offlineDirTitle: 'Офлайн-библиотека (в приложении)',
@@ -431,6 +429,22 @@ export const ruTranslation = {
offlineDirChange: 'Сменить папку',
offlineDirClear: 'Сбросить по умолчанию',
offlineDirHint: 'Новые загрузки пойдут в выбранную папку. Старые останутся на прежнем месте.',
hotCacheTitle: 'Горячий кэш воспроизведения',
hotCacheAlphaBadge: 'Альфа',
hotCacheDisclaimer: 'Заранее подгружает следующие треки из очереди и сохраняет предыдущие. При включении использует место на диске и сеть.',
hotCacheDirDefault: 'По умолчанию (данные приложения)',
hotCacheDirChange: 'Сменить папку',
hotCacheDirClear: 'Сбросить по умолчанию',
hotCacheDirHint: 'При смене папки сбрасывается список в приложении; уже скачанные файлы остаются на старом пути, пока вы их не удалите.',
hotCacheEnabled: 'Включить горячий кэш воспроизведения',
hotCacheMaxMb: 'Максимальный размер кэша',
hotCacheDebounce: 'Задержка после изменения очереди',
hotCacheDebounceImmediate: 'Сразу',
hotCacheDebounceSeconds: '{{n}} с',
hotCacheClearBtn: 'Очистить горячий кэш',
hiResTitle: 'Нативное воспроизведение Hi-Res',
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
hiResDesc: "По умолчанию ограничивает вывод до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
showArtistImages: 'Фото исполнителей',
showArtistImagesDesc:
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
@@ -438,16 +452,14 @@ export const ruTranslation = {
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
minimizeToTray: 'Сворачивать в трей',
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
discordRichPresence: 'Статус в Discord (Rich Presence)',
discordRichPresence: 'Статус в Discord',
discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord (Rich Presence). Нужен запущенный клиент Discord.',
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
nowPlayingEnabledDesc:
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
lyricsServerFirst: 'Приоритет серверного текста',
lyricsServerFirstDesc: 'Сначала запрашивать текст с сервера (теги, sidecar-файлы), затем LRCLIB. Отключите, чтобы использовать LRCLIB первым.',
downloadsTitle: 'Экспорт в ZIP и архивирование',
downloadsFolderDesc: 'Куда сохранять альбомы в виде ZIP на диск.',
downloadsTitle: 'Экспорт ZIP и архивы',
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
downloadsDefault: 'Папка «Загрузки» по умолчанию',
pickFolder: 'Выбрать',
pickFolderTitle: 'Папка для загрузок',
@@ -455,9 +467,9 @@ export const ruTranslation = {
logout: 'Выйти',
aboutTitle: 'О Psysonic',
aboutDesc:
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). Построен на Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain (выравнивание громкости), жанры и много тем оформления.',
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain, жанры и много тем оформления.',
aboutFeatures:
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain (выравнивание громкости) · жанры · десятки тем · несколько языков',
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain · жанры · десятки тем · несколько языков',
aboutLicense: 'Лицензия',
aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.',
aboutRepo: 'Исходный код на GitHub',
@@ -534,7 +546,7 @@ export const ruTranslation = {
preloadBalanced: 'Умеренно (за 30 с до конца)',
preloadEarly: 'Рано (через 5 с после старта)',
preloadCustom: 'Свой интервал',
preloadCustomSeconds: '{{n}} секунд до конца',
preloadCustomSeconds: 'Секунд до конца: {{n}}',
infiniteQueue: 'Бесконечная очередь',
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
experimental: 'Экспериментально',
@@ -590,7 +602,7 @@ export const ruTranslation = {
s5: 'Скробблинг',
q16: 'Как устроен скробблинг?',
a16:
'Psysonic отправляет прослушивания напрямую на Last.fm — в Navidrome ничего настраивать не нужно. Подключите аккаунт в настройках.',
'Psysonic шлёт прослушивания напрямую на Last.fm — в Navidrome ничего настраивать не нужно. Подключите аккаунт в настройках.',
q17: 'Когда уходит скроббл?',
a17: 'После прослушивания примерно половины трека.',
q22: 'Что за волна внизу?',
@@ -613,13 +625,13 @@ export const ruTranslation = {
s6: 'Если что-то не так',
q18: 'Медленно грузятся обложки.',
a18:
'Первый раз картинки загружаются с сервера и кэшируются на ~30 дней. Медленный диск на сервере даёт задержку только при первом заходе.',
'Первый раз картинки тянутся с сервера и кэшируются на ~30 дней. Медленный диск на сервере даёт задержку только при первом заходе.',
q19: 'Тест соединения не проходит.',
a19:
'Проверьте адрес с портом (http://192.168.1.100:4533), файрвол, для локальной сети иногда нужен http вместо https. Логин и пароль должны совпадать с сервером.',
'Проверьте адрес с портом (http://192.168.1.100:4533), файрвол, для локалки иногда нужен http вместо https. Логин и пароль должны совпадать с сервером.',
q20: 'Нет звука в Linux.',
a20:
'Убедитесь, что запущены PipeWire или PulseAudio.',
'Есть пакеты .deb, .rpm и AUR. Убедитесь, что запущены PipeWire или PulseAudio.',
q21: 'Чёрный экран в Linux.',
a21:
'Часто драйвер GPU / WebKitGTK. Запуск с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. В официальных пакетах это часто уже задано.',
@@ -640,7 +652,7 @@ export const ruTranslation = {
q35: 'Как сохранить альбом офлайн?',
a35:
'Страница альбома → иконка загрузки в шапке. Прогресс на кнопке, после загрузки — индикатор готовности. Список — в офлайн-библиотеке.',
q36: 'Сколько места занимает кэш?',
q36: 'Сколько места занимает офлайн?',
a36:
'Лимит задаётся в настройках. При переполнении на странице альбома появится предупреждение; удалить можно из офлайн-библиотеки.',
},
@@ -748,18 +760,15 @@ export const ruTranslation = {
volume: 'Громкость',
toggleQueue: 'Очередь',
lyrics: 'Текст',
fsLyricsToggle: 'Текст в полноэкранном режиме',
lyricsLoading: 'Загрузка текста…',
lyricsNotFound: 'Текст не найден',
lyricsSourceServer: 'Источник: сервер',
lyricsSourceLrclib: 'Источник: LRCLIB',
},
songInfo: {
title: 'О треке',
songTitle: 'Название',
artist: 'Исполнитель',
album: 'Альбом',
albumArtist: 'Исполнитель альбома',
albumArtist: 'Альбомный исполнитель',
year: 'Год',
genre: 'Жанр',
duration: 'Длительность',
@@ -803,7 +812,7 @@ export const ruTranslation = {
noSuggestions: 'Пока нет рекомендаций.',
titleBadge: 'Плейлист',
refreshSuggestions: 'Обновить подборку',
addSong: 'Добавить в плейлист',
addSong: 'В плейлист',
cacheOffline: 'Сохранить плейлист офлайн',
offlineCached: 'Плейлист сохранён',
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
+22 -9
View File
@@ -15,14 +15,13 @@ export const zhTranslation = {
help: '帮助',
expand: '展开侧边栏',
collapse: '收起侧边栏',
updateAvailable: '有可用更新',
updateReady: '{{version}} 已就绪',
updateLink: '前往发布页面 →',
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
offlineLibrary: '离线音乐库',
genres: '流派',
playlists: '播放列表',
radio: '网络电台',
libraryScope: '资料库范围',
allLibraries: '所有资料库',
},
home: {
hero: '精选',
@@ -324,12 +323,8 @@ export const zhTranslation = {
bulkRemoveFromPlaylist: '从播放列表移除',
bulkClear: '取消选择',
updaterAvailable: '有可用更新',
updaterVersion: 'v{{version}} 已就绪',
updaterInstall: '安装并重启',
updaterDownloading: '下载中…',
updaterInstalling: '安装中…',
updaterDownload: '从 GitHub 下载',
updaterExperimentalHint: '自动更新功能仍在开发中',
updaterVersion: 'v{{version}} 已发布',
updaterWebsite: '官网',
},
settings: {
title: '设置',
@@ -400,6 +395,8 @@ export const zhTranslation = {
cacheDesc: '封面和艺术家图片。存满时,最旧的条目将自动移除。离线专辑不会自动移除,但手动清除缓存时会被删除。',
cacheUsedImages: '图片:',
cacheUsedOffline: '离线曲目:',
cacheUsedHot: '占用空间:',
hotCacheTrackCount: '缓存曲目数:',
cacheMaxLabel: '最大容量',
cacheClearBtn: '清除缓存',
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
@@ -411,6 +408,22 @@ export const zhTranslation = {
offlineDirChange: '更改目录',
offlineDirClear: '重置为默认',
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
hotCacheTitle: '热播放缓存',
hotCacheAlphaBadge: '预览',
hotCacheDisclaimer: '预加载队列中即将播放的曲目并保留近期已播放的。开启后占用磁盘与网络。',
hotCacheDirDefault: '默认(应用数据)',
hotCacheDirChange: '更改目录',
hotCacheDirClear: '重置为默认',
hotCacheDirHint: '更换目录会重置应用内索引;已下载的文件仍留在原路径,需自行删除。',
hotCacheEnabled: '启用热播放缓存',
hotCacheMaxMb: '最大缓存大小',
hotCacheDebounce: '队列变更去抖',
hotCacheDebounceImmediate: '立即',
hotCacheDebounceSeconds: '{{n}} 秒',
hotCacheClearBtn: '清空热缓存',
hiResTitle: '原生高清晰度播放',
hiResEnabled: '启用原生高清晰度播放',
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
showArtistImages: '显示艺术家图片',
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
showTrayIcon: '显示托盘图标',
+5 -3
View File
@@ -11,6 +11,7 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import CustomSelect from '../components/CustomSelect';
import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
@@ -31,6 +32,7 @@ interface Results {
export default function AdvancedSearch() {
const { t } = useTranslation();
const [params] = useSearchParams();
const qFromUrl = params.get('q') ?? '';
const navigate = useNavigate();
const psyDrag = useDragDrop();
const playTrack = usePlayerStore(s => s.playTrack);
@@ -46,6 +48,7 @@ export default function AdvancedSearch() {
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [genreNote, setGenreNote] = useState(false);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const runSearch = async (opts: SearchOpts) => {
setLoading(true);
@@ -109,9 +112,8 @@ export default function AdvancedSearch() {
getGenres().then(data =>
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
).catch(() => {});
const q = params.get('q') ?? '';
if (q) runSearch({ query: q, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
}, []);
if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
}, [musicLibraryFilterVersion, qFromUrl]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
+4 -2
View File
@@ -3,6 +3,7 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { X } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
@@ -18,6 +19,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function Albums() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [sort, setSort] = useState<SortType>('alphabeticalByName');
const [loading, setLoading] = useState(true);
@@ -50,7 +52,7 @@ export default function Albums() {
} finally {
setLoading(false);
}
}, []);
}, [musicLibraryFilterVersion]);
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
setLoading(true);
@@ -66,7 +68,7 @@ export default function Albums() {
} finally {
setLoading(false);
}
}, []);
}, [musicLibraryFilterVersion]);
useEffect(() => {
setPage(0);
+3 -2
View File
@@ -70,6 +70,7 @@ export default function ArtistDetail() {
const isPlaying = usePlayerStore(state => state.isPlaying);
const { downloadArtist, bulkProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
if (!id) return;
@@ -126,7 +127,7 @@ export default function ArtistDetail() {
setFeaturedAlbums([...albumMap.values()]);
setFeaturedLoading(false);
});
}, [artist?.id]);
}, [artist?.id, musicLibraryFilterVersion]);
useEffect(() => {
if (!artist || !lastfmIsConfigured()) return;
@@ -152,7 +153,7 @@ export default function ArtistDetail() {
setSimilarArtists(found);
setSimilarLoading(false);
}).catch(() => setSimilarLoading(false));
}, [artist?.id]);
}, [artist?.id, musicLibraryFilterVersion]);
const openLink = (url: string, key: string) => {
open(url);
+2 -1
View File
@@ -86,10 +86,11 @@ export default function Artists() {
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
}, []);
}, [musicLibraryFilterVersion]);
const loadMore = useCallback(() => {
setVisibleCount(prev => prev + 50);
+3 -1
View File
@@ -14,6 +14,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
@@ -63,6 +64,7 @@ export default function Favorites() {
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const navigate = useNavigate();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
const loadAll = async () => {
@@ -87,7 +89,7 @@ export default function Favorites() {
setLoading(false);
};
loadAll();
}, []);
}, [musicLibraryFilterVersion]);
if (loading) {
return (
+2
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Disc3 } from 'lucide-react';
import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import AlbumCard from '../components/AlbumCard';
const PAGE_SIZE = 50;
@@ -17,6 +18,7 @@ export default function GenreDetail() {
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
setAlbums([]);
+2 -1
View File
@@ -7,6 +7,7 @@ import {
Tags, type LucideIcon,
} from 'lucide-react';
import { getGenres, SubsonicGenre } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
function getGenreIcon(name: string): LucideIcon {
const n = name.toLowerCase();
@@ -59,7 +60,7 @@ export default function Genres() {
setGenres(sorted);
})
.finally(() => setLoading(false));
}, []);
}, []); // getGenres is not folder-scoped — no dep on musicLibraryFilterVersion
// Restore scroll position after genres are rendered
useEffect(() => {
+3 -1
View File
@@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore';
import { useAuthStore } from '../store/authStore';
export default function Home() {
const homeSections = useHomeStore(s => s.sections);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -44,7 +46,7 @@ export default function Home() {
setRandomArtists(shuffled.slice(0, 16));
setLoading(false);
}).catch(() => setLoading(false));
}, []);
}, [musicLibraryFilterVersion, homeSections]);
const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
+3 -1
View File
@@ -4,6 +4,7 @@ import { ChevronLeft } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import { search, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
export default function LabelAlbums() {
const { t } = useTranslation();
@@ -11,6 +12,7 @@ export default function LabelAlbums() {
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
if (!name) return;
@@ -31,7 +33,7 @@ export default function LabelAlbums() {
})
.catch(console.error)
.finally(() => setLoading(false));
}, [name]);
}, [name, musicLibraryFilterVersion]);
return (
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
+3 -1
View File
@@ -3,6 +3,7 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
const PAGE_SIZE = 30;
@@ -15,6 +16,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function NewReleases() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
@@ -43,7 +45,7 @@ export default function NewReleases() {
} finally {
setLoading(false);
}
}, []);
}, [musicLibraryFilterVersion]);
useEffect(() => {
if (filtered) loadFiltered(selectedGenres);
+3 -1
View File
@@ -4,6 +4,7 @@ import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
const ALBUM_COUNT = 30;
@@ -21,6 +22,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function RandomAlbums() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
@@ -42,7 +44,7 @@ export default function RandomAlbums() {
loadingRef.current = false;
setLoading(false);
}
}, []);
}, [musicLibraryFilterVersion]);
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
+2 -1
View File
@@ -36,6 +36,7 @@ export default function RandomMix() {
const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null);
@@ -82,7 +83,7 @@ export default function RandomMix() {
setAllAvailableGenres(available);
setDisplayedGenres(available.slice(0, 20));
}).catch(() => {});
}, []);
}, [musicLibraryFilterVersion]);
const filteredSongs = songs.filter(song => {
if (!excludeAudiobooks) return true;
+3 -1
View File
@@ -7,6 +7,7 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
@@ -21,6 +22,7 @@ export default function SearchResults() {
const playTrack = usePlayerStore(s => s.playTrack);
const currentTrack = usePlayerStore(s => s.currentTrack);
const psyDrag = useDragDrop();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
if (!query.trim()) { setResults(null); return; }
@@ -28,7 +30,7 @@ export default function SearchResults() {
search(query, { artistCount: 20, albumCount: 20, songCount: 50 })
.then(r => setResults(r))
.finally(() => setLoading(false));
}, [query]);
}, [query, musicLibraryFilterVersion]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
+221 -2
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
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves
} from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
@@ -13,6 +13,7 @@ import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
@@ -88,6 +89,7 @@ const CONTRIBUTORS = [
contributions: [
'Russian translation & i18n locale split (PR #106)',
'Gapless manual skip: honor user-initiated play over pre-chained track (PR #119)',
'Per-server music folder filter and sidebar library picker (PR #125)',
],
},
{
@@ -181,6 +183,12 @@ function formatBytes(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
/** Align hot-cache size slider (step 32 MB) to valid values. */
function snapHotCacheMb(v: number): number {
const x = Math.min(20000, Math.max(32, Math.round(v)));
return Math.round((x - 32) / 32) * 32 + 32;
}
export default function Settings() {
const auth = useAuthStore();
const theme = useThemeStore();
@@ -189,6 +197,13 @@ export default function Settings() {
const gs = useGlobalShortcutsStore();
const serverId = auth.activeServerId ?? '';
const clearAllOffline = useOfflineStore(s => s.clearAll);
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
const hotCacheEntries = useHotCacheStore(s => s.entries);
const hotCacheTrackCount = useMemo(() => {
if (!serverId) return 0;
const prefix = `${serverId}:`;
return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length;
}, [hotCacheEntries, serverId]);
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
const navigate = useNavigate();
@@ -205,6 +220,7 @@ export default function Settings() {
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false);
const [contributorsOpen, setContributorsOpen] = useState(false);
@@ -218,7 +234,8 @@ export default function Settings() {
if (activeTab !== 'storage') return;
getImageCacheSize().then(setImageCacheBytes);
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
}, [activeTab]);
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
const handleClearCache = useCallback(async () => {
setClearing(true);
@@ -339,6 +356,15 @@ export default function Settings() {
}
};
const pickHotCacheDir = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.hotCacheDirChange') });
if (selected && typeof selected === 'string') {
auth.setHotCacheDownloadDir(selected);
useHotCacheStore.setState({ entries: {} });
invoke<number>('get_hot_cache_size', { customDir: selected }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}
};
const pickDownloadFolder = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
if (selected && typeof selected === 'string') {
@@ -520,6 +546,41 @@ export default function Settings() {
</div>
</section>
{/* Native Hi-Res Playback */}
<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>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
<input
type="checkbox"
checked={auth.enableHiRes}
onChange={e => auth.setEnableHiRes(e.target.checked)}
id="hires-enabled-toggle"
/>
<span className="toggle-track" />
</label>
</div>
</div>
</section>
</>
)}
@@ -788,6 +849,164 @@ 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">
+4 -3
View File
@@ -33,6 +33,7 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
export default function Statistics() {
const { t } = useTranslation();
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
@@ -73,7 +74,7 @@ export default function Statistics() {
setGenres(sorted);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
}, [musicLibraryFilterVersion]);
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
useEffect(() => {
@@ -102,7 +103,7 @@ export default function Statistics() {
}
})();
return () => { cancelled = true; };
}, []);
}, [musicLibraryFilterVersion]);
// Background fetch: format distribution (sample of 500 random songs)
useEffect(() => {
@@ -121,7 +122,7 @@ export default function Statistics() {
setFormatSampleSize(songs.length);
}).catch(() => {});
return () => { cancelled = true; };
}, []);
}, [musicLibraryFilterVersion]);
useEffect(() => {
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
+70 -2
View File
@@ -47,6 +47,26 @@ interface AuthState {
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
/** Alpha: ephemeral queue prefetch cache on disk */
hotCacheEnabled: boolean;
hotCacheMaxMb: number;
hotCacheDebounceSec: number;
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
hotCacheDownloadDir: string;
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>;
/**
* Per server: `all` = no musicFolderId param; otherwise a single folder id.
* Only one library or all no multi-folder merge.
*/
musicLibraryFilterByServer: Record<string, 'all' | string>;
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
musicLibraryFilterVersion: number;
// Status
isLoggedIn: boolean;
isConnecting: boolean;
@@ -88,6 +108,13 @@ interface AuthState {
setShowFullscreenLyrics: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
setEnableHiRes: (v: boolean) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
setHotCacheDownloadDir: (v: string) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void;
logout: () => void;
// Derived
@@ -131,6 +158,14 @@ export const useAuthStore = create<AuthState>()(
showFullscreenLyrics: true,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
enableHiRes: false,
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
hotCacheDownloadDir: '',
musicFolders: [],
musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0,
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -160,7 +195,7 @@ export const useAuthStore = create<AuthState>()(
});
},
setActiveServer: (id) => set({ activeServerId: id }),
setActiveServer: (id) => set({ activeServerId: id, musicFolders: [] }),
setLoggedIn: (v) => set({ isLoggedIn: v }),
setConnecting: (v) => set({ isConnecting: v }),
@@ -207,7 +242,36 @@ export const useAuthStore = create<AuthState>()(
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
logout: () => set({ isLoggedIn: false }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
setMusicFolders: (folders) => {
const sid = get().activeServerId;
set(s => {
const f = sid ? s.musicLibraryFilterByServer[sid] : undefined;
const invalidFilter = f && f !== 'all' && !folders.some(x => x.id === f);
return {
musicFolders: folders,
...(sid && invalidFilter
? { musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: 'all' } }
: {}),
};
});
},
setMusicLibraryFilter: (folderId) => {
const sid = get().activeServerId;
if (!sid) return;
set(s => ({
musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: folderId },
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
}));
},
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
getBaseUrl: () => {
const s = get();
@@ -224,6 +288,10 @@ export const useAuthStore = create<AuthState>()(
{
name: 'psysonic-auth',
storage: createJSONStorage(() => localStorage),
partialize: state => {
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
return rest;
},
}
)
);
+183
View File
@@ -0,0 +1,183 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import type { Track } from './playerStore';
const PREFETCH_AHEAD = 5;
export interface HotCacheEntry {
localPath: string;
sizeBytes: number;
cachedAt: number;
/** Last time this file was started as the current track (eviction tie-break: newer = keep longer). */
lastPlayedAt?: number;
}
interface HotCacheState {
/** Persisted map `${serverId}:${trackId}` → file meta */
entries: Record<string, HotCacheEntry>;
getLocalUrl: (trackId: string, serverId: string) => string | null;
setEntry: (trackId: string, serverId: string, localPath: string, sizeBytes: number) => void;
/** Bump LRU when the user actually plays this track (if it is in the hot cache). */
touchPlayed: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => void;
totalBytes: () => number;
/** Evict until total size ≤ maxBytes. Respects queue tail first, protects current + next N. */
evictToFit: (
queue: Track[],
queueIndex: number,
maxBytes: number,
activeServerId: string,
hotCacheCustomDir: string | null,
) => Promise<void>;
clearAllDisk: (customDir: string | null) => Promise<void>;
}
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
function parseKey(key: string): { serverId: string; trackId: string } | null {
const i = key.indexOf(':');
if (i <= 0) return null;
return { serverId: key.slice(0, i), trackId: key.slice(i + 1) };
}
function lruStamp(meta: HotCacheEntry | undefined): number {
if (!meta) return 0;
return meta.lastPlayedAt ?? meta.cachedAt ?? 0;
}
export const useHotCacheStore = create<HotCacheState>()(
persist(
(set, get) => ({
entries: {},
getLocalUrl: (trackId, serverId) => {
const e = get().entries[entryKey(serverId, trackId)];
if (!e?.localPath) return null;
return `psysonic-local://${e.localPath}`;
},
setEntry: (trackId, serverId, localPath, sizeBytes) => {
const now = Date.now();
set(s => ({
entries: {
...s.entries,
[entryKey(serverId, trackId)]: {
localPath,
sizeBytes,
cachedAt: now,
lastPlayedAt: now,
},
},
}));
},
touchPlayed: (trackId, serverId) => {
const k = entryKey(serverId, trackId);
set(s => {
const e = s.entries[k];
if (!e) return s;
return {
entries: {
...s.entries,
[k]: { ...e, lastPlayedAt: Date.now() },
},
};
});
},
removeEntry: (trackId, serverId) => {
set(s => {
const next = { ...s.entries };
delete next[entryKey(serverId, trackId)];
return { entries: next };
});
},
totalBytes: () =>
Object.values(get().entries).reduce((acc, e) => acc + (e.sizeBytes || 0), 0),
evictToFit: async (queue, queueIndex, maxBytes, activeServerId, hotCacheCustomDir) => {
if (maxBytes <= 0) return;
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + PREFETCH_AHEAD);
const protectedIds = new Set<string>();
for (let i = protectLo; i <= protectHi; i++) {
protectedIds.add(queue[i].id);
}
const indexOfInQueue = (trackId: string): number | null => {
const idx = queue.findIndex(t => t.id === trackId);
return idx >= 0 ? idx : null;
};
let entries = { ...get().entries };
let sum = Object.values(entries).reduce((a, e) => a + (e.sizeBytes || 0), 0);
if (sum <= maxBytes) return;
const keys = Object.keys(entries);
type Cand = { key: string; tier: number; primary: number; lru: number };
const cands: Cand[] = [];
for (const key of keys) {
const parsed = parseKey(key);
if (!parsed) continue;
const { serverId, trackId } = parsed;
if (protectedIds.has(trackId) && serverId === activeServerId) continue;
const meta = entries[key];
const lru = lruStamp(meta);
if (serverId !== activeServerId) {
cands.push({ key, tier: 0, primary: 0, lru });
continue;
}
const qIdx = indexOfInQueue(trackId);
if (qIdx === null) {
cands.push({ key, tier: 1, primary: 0, lru });
} else if (qIdx > protectHi) {
cands.push({ key, tier: 2, primary: -qIdx, lru });
} else if (qIdx < protectLo) {
cands.push({ key, tier: 3, primary: qIdx, lru });
}
}
cands.sort((a, b) => {
if (a.tier !== b.tier) return a.tier - b.tier;
if (a.primary !== b.primary) return a.primary - b.primary;
return a.lru - b.lru;
});
for (const { key } of cands) {
if (sum <= maxBytes) break;
const meta = entries[key];
if (!meta) continue;
const parsed = parseKey(key);
if (!parsed) continue;
await invoke('delete_hot_cache_track', {
localPath: meta.localPath,
customDir: hotCacheCustomDir || null,
}).catch(() => {});
sum -= meta.sizeBytes || 0;
delete entries[key];
}
set({ entries });
},
clearAllDisk: async (customDir: string | null) => {
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
set({ entries: {} });
},
}),
{
name: 'psysonic-hot-cache',
storage: createJSONStorage(() => localStorage),
partialize: s => ({ entries: s.entries }),
},
),
);
+29 -5
View File
@@ -3,10 +3,13 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { showToast } from '../utils/toast';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore';
import { useHotCacheStore } from './hotCacheStore';
export interface Track {
id: string;
@@ -209,6 +212,11 @@ radioAudio.addEventListener('suspend', () => {
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
function touchHotCacheOnPlayback(trackId: string, serverId: string) {
if (!trackId || !serverId) return;
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.
@@ -230,6 +238,7 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
function handleAudioPlaying(_duration: number) {
setDeferHotCachePrefetch(false);
usePlayerStore.setState({ isPlaying: true });
}
@@ -281,7 +290,7 @@ function handleAudioProgress(current_time: number, duration: number) {
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id;
const serverId = useAuthStore.getState().activeServerId ?? '';
const nextUrl = useOfflineStore.getState().getLocalUrl(nextTrack.id, serverId) ?? buildStreamUrl(nextTrack.id);
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
@@ -301,6 +310,7 @@ function handleAudioProgress(current_time: number, duration: number) {
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
hiResEnabled: useAuthStore.getState().enableHiRes,
}).catch(() => {});
} else {
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
@@ -390,6 +400,7 @@ function handleAudioTrackSwitched(duration: number) {
});
}
syncQueueToServer(queue, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? '');
}
function handleAudioError(message: string) {
@@ -721,7 +732,8 @@ export const usePlayerStore = create<PlayerState>()(
});
const authState = useAuthStore.getState();
const url = useOfflineStore.getState().getLocalUrl(track.id, authState.activeServerId ?? '') ?? buildStreamUrl(track.id);
setDeferHotCachePrefetch(true);
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
: null;
@@ -733,8 +745,10 @@ export const usePlayerStore = create<PlayerState>()(
replayGainDb,
replayGainPeak,
manual,
hiResEnabled: authState.enableHiRes,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
setTimeout(() => {
@@ -757,6 +771,7 @@ export const usePlayerStore = create<PlayerState>()(
});
}
syncQueueToServer(newQueue, track, 0);
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
},
// ── pause / resume / togglePlay ──────────────────────────────────────────
@@ -784,6 +799,7 @@ export const usePlayerStore = create<PlayerState>()(
invoke('audio_resume').catch(console.error);
isAudioPaused = false;
set({ isPlaying: true });
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
} else {
// Cold start (app relaunch) — fetch fresh track data for replay gain, then play.
const gen = ++playGeneration;
@@ -801,7 +817,9 @@ export const usePlayerStore = create<PlayerState>()(
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(trackToPlay.id, coldServerId) ?? buildStreamUrl(trackToPlay.id);
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
@@ -809,12 +827,14 @@ export const usePlayerStore = create<PlayerState>()(
replayGainDb: replayGainDbCold,
manual: false,
replayGainPeak: replayGainPeakCold,
hiResEnabled: useAuthStore.getState().enableHiRes,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
@@ -828,7 +848,9 @@ export const usePlayerStore = create<PlayerState>()(
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id);
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
@@ -836,8 +858,10 @@ export const usePlayerStore = create<PlayerState>()(
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
+29 -22
View File
@@ -3028,7 +3028,7 @@
z-index: 4;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
max-width: 85vw;
}
@@ -3066,30 +3066,28 @@
color: rgba(255, 255, 255, 0.3);
}
/* Artist name — massive, font-black, uppercase, wraps gracefully */
.fs-artist-name {
/* Track title — massive, font-black, primary — now on top */
.fs-track-title {
font-family: var(--font-display);
font-size: clamp(28px, 4.5vw, 68px);
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.02em;
color: #ffffff;
color: var(--accent);
margin: 0;
line-height: 1.05;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.7);
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5), 0 0 40px var(--accent-glow, var(--accent));
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Track title — large, light, accent color, wraps gracefully */
.fs-track-title {
/* Artist name — secondary, below track title */
.fs-artist-name {
font-family: var(--font-display);
font-size: clamp(18px, 2.6vw, 40px);
font-weight: 300;
color: var(--accent);
font-size: clamp(13px, 1.5vw, 22px);
font-weight: 400;
color: rgba(255, 255, 255, 0.55);
margin: 0;
line-height: 1.2;
line-height: 1.3;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
@@ -3103,6 +3101,7 @@
flex-wrap: wrap;
font-size: clamp(11px, 1vw, 13px);
color: rgba(255, 255, 255, 0.5);
margin-top: 4px;
}
.fs-meta-dot {
@@ -3250,18 +3249,19 @@
}
/* ── Lyrics overlay — upper-left quadrant, strictly 5 lines tall ── */
/* Slot = 3.6vh → 5 × 3.6vh = 18vh. JS reads window.innerHeight * 0.036. */
/* Slot = 6vh → 5 × 6vh = 30vh. JS reads window.innerHeight * 0.06. */
.fs-lyrics-overlay {
position: absolute;
top: 15vh; /* pushed down into the quadrant, clear of close button */
left: clamp(28px, 4vw, 64px);
right: 52%; /* stay out of portrait area */
height: 18vh; /* exactly 5 × 3.6vh — never grows, never overlaps cluster */
height: 30vh; /* exactly 5 × 6vh — never grows, never overlaps cluster */
z-index: 3;
overflow: hidden;
pointer-events: none;
contain: layout style; /* isolate reflows from rest of page */
/* Fade first and last slot (each 3.6/18 = 20% of container) */
/* Fade first and last slot (each 6/30 = 20% of container) */
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
@@ -3288,19 +3288,25 @@
transition: transform 500ms ease-out;
}
/* Each lyric slot is exactly 3.6vh — matches JS window.innerHeight * 0.036 */
/* Each lyric slot is exactly 6vh matches JS window.innerHeight * 0.06.
Fixed height (not min-height) is intentional: keeps rail math correct
regardless of whether the text wraps or not. 2 wrapped lines of 2vh
font at 1.4 line-height = 5.6vh, comfortably inside the 6vh slot. */
.fs-lyric-line {
height: 3.6vh;
height: 6vh;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
font-size: 2.2vh;
white-space: normal;
font-size: 2vh;
line-height: 1.4;
color: rgba(255, 255, 255, 0.22);
font-weight: 400;
cursor: pointer;
pointer-events: auto;
transition: color 280ms ease, font-weight 280ms ease, text-shadow 280ms ease;
/* font-weight intentionally NOT transitioned — triggers layout reflow on every frame */
transition: color 280ms ease, text-shadow 280ms ease, transform 280ms ease;
transform-origin: left center;
user-select: none;
}
@@ -3312,11 +3318,12 @@
color: rgba(255, 255, 255, 0.1);
}
/* Active: emphasis via weight + glow only, height stays 44px */
/* Active: emphasis via scale + glow; font-weight snaps instantly (no layout-reflow transition) */
.fs-lyric-line.fsl-active {
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
transform: scaleX(1.015); /* subtle compositor-only emphasis, no layout cost */
}
+159
View File
@@ -93,6 +93,165 @@
overflow-y: auto;
}
/* Library scope: Navidrome-style dropdown trigger + fixed panel (portal) */
.nav-library-scope-trigger {
display: flex;
align-items: flex-start;
gap: var(--space-2);
width: calc(100% - 2 * var(--space-3));
margin: var(--space-3) var(--space-3) var(--space-2);
padding: var(--space-2) var(--space-2);
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-md);
background: var(--bg-tertiary, rgba(0, 0, 0, 0.2));
color: var(--text-secondary);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.nav-library-scope-trigger:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-default, rgba(255, 255, 255, 0.12));
}
/* «Все библиотеки»: как секция навигации, без рамки и подложки */
.nav-library-scope-trigger--plain {
border-color: transparent;
background: transparent;
align-items: center;
}
.nav-library-scope-trigger--plain:hover {
border-color: transparent;
}
.nav-library-scope-trigger--plain .nav-library-scope-chevron {
margin-top: 0;
}
.nav-library-scope-trigger--open:not(.nav-library-scope-trigger--plain) {
border-color: var(--accent, var(--border-default));
}
.nav-library-scope-trigger--open.nav-library-scope-trigger--plain {
border-color: var(--border-subtle, rgba(255, 255, 255, 0.08));
background: var(--bg-tertiary, rgba(0, 0, 0, 0.2));
}
.nav-library-scope-icon {
flex-shrink: 0;
margin-top: 2px;
opacity: 0.85;
}
.nav-library-scope-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-library-scope-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.nav-library-scope-subtitle {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-library-scope-chevron {
flex-shrink: 0;
margin-top: 2px;
opacity: 0.7;
transition: transform var(--transition-fast);
}
.nav-library-scope-trigger--open .nav-library-scope-chevron {
transform: rotate(180deg);
}
.nav-library-dropdown-panel {
z-index: 10050;
display: flex;
flex-direction: column;
padding: var(--space-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dropdown, rgba(255, 255, 255, 0.12));
background: var(--bg-secondary, #1e1e2e);
box-shadow: 0 8px 24px var(--shadow-dropdown, rgba(0, 0, 0, 0.45));
box-sizing: border-box;
overflow-y: visible;
}
/* >10 папок: «Все библиотеки» + до 10 строк папок, дальше — прокрутка */
.nav-library-dropdown-panel--many-libraries {
--nav-lib-dropdown-row-h: calc(2 * var(--space-2) + 1.35rem);
max-height: min(
calc(11 * var(--nav-lib-dropdown-row-h) + 2 * var(--space-1)),
70vh
);
overflow-y: auto;
}
.nav-library-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
margin: 0;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background var(--transition-fast);
}
.nav-library-dropdown-item:hover {
background: var(--bg-hover);
}
.nav-library-dropdown-item--selected {
color: var(--accent);
}
.nav-library-dropdown-item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-library-dropdown-check {
flex-shrink: 0;
color: var(--accent);
}
.nav-library-dropdown-check-spacer {
flex-shrink: 0;
width: 16px;
height: 16px;
}
.nav-section-label {
font-size: 10px;
font-weight: 600;
+10
View File
@@ -0,0 +1,10 @@
/** When true, hot-cache prefetch must not start new downloads (playback has priority). */
let deferHotCachePrefetch = false;
export function setDeferHotCachePrefetch(v: boolean): void {
deferHotCachePrefetch = v;
}
export function getDeferHotCachePrefetch(): boolean {
return deferHotCachePrefetch;
}
+12
View File
@@ -0,0 +1,12 @@
import { buildStreamUrl } from '../api/subsonic';
import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore';
/** Offline library → hot playback cache → HTTP stream. */
export function resolvePlaybackUrl(trackId: string, serverId: string): string {
const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId);
if (offline) return offline;
const hot = useHotCacheStore.getState().getLocalUrl(trackId, serverId);
if (hot) return hot;
return buildStreamUrl(trackId);
}