mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Merge branch 'main' into feat/waveform-loudness-cache
This commit is contained in:
@@ -1,165 +1,196 @@
|
|||||||
<div align="center">
|
# Psysonic
|
||||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="300"/>
|
|
||||||
|
|
||||||
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
|
|
||||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
|
||||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
|
||||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
|
||||||
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
|
|
||||||
<a href="https://psysonic.cachix.org"><img alt="Cachix" src="https://img.shields.io/badge/Cachix-psysonic-5277c3?style=flat-square&logo=nixos&logoColor=white"></a>
|
|
||||||
<a href="https://discord.gg/AMnDRErm4u"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://discord.gg/AMnDRErm4u">
|
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="320"/>
|
||||||
<img src="https://img.shields.io/badge/Join%20the%20Psysonic%20Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"/>
|
|
||||||
</a>
|
## The Ultimate Desktop Client for Self-Hosted Music Libraries
|
||||||
<p>Have questions, ideas, or just want to hang out? Come chat in our Discord server!</p>
|
|
||||||
|
**Fast. Beautiful. Native. Feature-packed.**
|
||||||
|
Built primarily for **Navidrome**. Also compatible with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers with partial feature support.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri"></a> <a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white"></a> <a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white"></a>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
**No telemetry • Native performance • Massive feature set • Community driven**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
|
|
||||||
|
|
||||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ Features
|
---
|
||||||
|
|
||||||
- 🎨 **Wide Theme Selection**: Dozens of themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, and Psysonic originals. Glassmorphism effects, micro-animations, and a time-based **Theme Scheduler** for automatic day/night switching.
|
> [!WARNING]
|
||||||
- ⚡ **Native Performance**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage, no Electron overhead.
|
> Psysonic is under heavy active development. Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
||||||
- 🎵 **Last.fm Integration**: Scrobbling, Now Playing, love/unlove, Similar Artists, and top stats — no Navidrome config needed.
|
|
||||||
- 🎤 **Synchronized Lyrics**: Auto-scrolling synced lyrics with click-to-seek in the sidebar and fullscreen player, powered by LRCLIB and your Navidrome server.
|
|
||||||
- 📻 **Radio & Infinite Queue**: Smart Radio sessions from any song or artist, built-in Internet Radio (ICY/HLS), and an Infinite Queue that silently refills when the queue runs out.
|
|
||||||
- 🎛️ **Advanced Audio**: 10-band graphic EQ with custom presets, **AutoEQ** headphone correction, Replay Gain, gapless playback, and crossfade.
|
|
||||||
- 〰️ **10 Seekbar Styles**: Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
|
|
||||||
- 🖥️ **Fullscreen Player**: Album art, animated synced lyrics overlay, and artist image in a dedicated fullscreen view.
|
|
||||||
- 📋 **Playlists & Library**: Full playlist management with drag-and-drop reorder and smart suggestions. Genre browsing, Random Mix, Advanced Search, ratings (1–5 stars), and multi-select actions.
|
|
||||||
- 💾 **Device Sync**: Export your library to a USB drive or portable device using a configurable filename template.
|
|
||||||
- 🖥️ **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
|
|
||||||
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
|
|
||||||
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
|
|
||||||
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm, NixOS flake).
|
|
||||||
- ❄️ **NixOS / flakes**: First-class flake package with a public **Cachix** binary cache (`psysonic.cachix.org`) — `nix run github:Psychotoxical/psysonic` or add to your system config. See the [NixOS install guide](./nixos-install.md).
|
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
## Server Compatibility
|
||||||
|
|
||||||
### 📋 Planned
|
**Psysonic is optimized first and foremost for Navidrome.**
|
||||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all themes
|
|
||||||
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
|
Many advanced functions integrate directly with Navidrome APIs for the best possible experience. Other Subsonic-compatible servers generally work well, but some features may be limited depending on server capabilities.
|
||||||
- [ ] More languages
|
|
||||||
|
## Why Psysonic?
|
||||||
|
|
||||||
|
Most Subsonic clients feel like web wrappers.
|
||||||
|
|
||||||
|
**Psysonic does not.**
|
||||||
|
|
||||||
|
It is a true desktop experience built with **Rust**, **Tauri v2**, and **React** for users who care about speed, aesthetics, customization, and serious music library management.
|
||||||
|
|
||||||
|
If you host your own music, this is what the premium experience should feel like.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📥 Installation
|
# Core Features
|
||||||
|
|
||||||
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
|
## Playback Engine
|
||||||
|
|
||||||
### 🐧 Linux
|
* Gapless playback
|
||||||
|
* Crossfade
|
||||||
|
* ReplayGain support
|
||||||
|
* Smart Loudness Normalization
|
||||||
|
* Infinite Queue
|
||||||
|
* Smart Radio sessions
|
||||||
|
* High responsiveness with low memory usage
|
||||||
|
|
||||||
|
## Audio Tools
|
||||||
|
|
||||||
|
* 10-band Equalizer
|
||||||
|
* Presets
|
||||||
|
* AutoEQ headphone correction
|
||||||
|
* Per-device optimization
|
||||||
|
|
||||||
|
## Library Power
|
||||||
|
|
||||||
|
* Lightning-fast search
|
||||||
|
* Albums / Artists / Tracks / Genres
|
||||||
|
* Ratings system
|
||||||
|
* Multi-select bulk actions
|
||||||
|
* Drag & drop playlist management
|
||||||
|
* Huge library friendly
|
||||||
|
|
||||||
|
## Lyrics & Discovery
|
||||||
|
|
||||||
|
* Synced lyrics with seek support
|
||||||
|
* Auto-scroll sidebar lyrics
|
||||||
|
* Fullscreen lyric mode
|
||||||
|
* Last.fm scrobbling
|
||||||
|
* Similar artists / love tracks / stats
|
||||||
|
|
||||||
|
## Personalization
|
||||||
|
|
||||||
|
* Huge theme collection
|
||||||
|
* Catppuccin / Nord inspired styles
|
||||||
|
* Glassmorphism effects
|
||||||
|
* Font customization
|
||||||
|
* Zoom controls
|
||||||
|
* Keybind remapping
|
||||||
|
* Theme Scheduler (day/night auto switch)
|
||||||
|
|
||||||
|
## Power User Extras
|
||||||
|
|
||||||
|
* CLI controls
|
||||||
|
* USB / portable sync
|
||||||
|
* Backup & restore settings
|
||||||
|
* In-app auto updater
|
||||||
|
* LAN / remote auto switching
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Orbit (Upcoming)
|
||||||
|
|
||||||
|
## Listen Together. In Sync. Soon.
|
||||||
|
|
||||||
|
Currently in final development and testing. Orbit will introduce synchronized shared listening sessions directly inside Psysonic.
|
||||||
|
|
||||||
|
* Host-controlled playback
|
||||||
|
* Join via link
|
||||||
|
* Shared listening sessions
|
||||||
|
* Guest song suggestions
|
||||||
|
* Real-time queue interaction
|
||||||
|
|
||||||
|
**Rolling out in an upcoming release. Community feedback will help shape the final experience.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Platforms
|
||||||
|
|
||||||
|
| OS | Support |
|
||||||
|
| ------- | --------------------------------------------------------------- |
|
||||||
|
| Windows | Native Installer *(certificate pending)* |
|
||||||
|
| macOS | Signed DMG |
|
||||||
|
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||||
|
|
||||||
|
Supports **8 languages** and growing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Install
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
**Quick Install (Recommended):**
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manual Installation:**
|
## Windows
|
||||||
- **Ubuntu / Debian**: `.deb` from GitHub Releases
|
|
||||||
- **Fedora / RHEL**: `.rpm` from GitHub Releases
|
|
||||||
- **Any distro (portable)**: `.AppImage` from GitHub Releases — `chmod +x` and run, no install required
|
|
||||||
|
|
||||||
**Arch Linux (AUR):**
|
Download the latest installer from Releases.
|
||||||
|
|
||||||
| Package | Type | Description |
|
> SmartScreen warnings may appear until the code-signing certificate is active.
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| [**psysonic**](https://aur.archlinux.org/packages/psysonic) | **Source** | Builds from source using your system's native **WebKitGTK** (no bundled libs, no EGL/Mesa compatibility issues). |
|
|
||||||
| [**psysonic-bin**](https://aur.archlinux.org/packages/psysonic-bin) | **Binary** | Pre-compiled version for faster installation. |
|
|
||||||
|
|
||||||
> [!TIP]
|
## macOS
|
||||||
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
|
|
||||||
|
|
||||||
**❄️ NixOS (flakes):**
|
Download the signed DMG from Releases.
|
||||||
- `nix run github:Psychotoxical/psysonic` — one-shot launch
|
|
||||||
- Full guide: [`nixos-install.md`](./nixos-install.md) *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#209](https://github.com/Psychotoxical/psysonic/pull/209))*
|
|
||||||
|
|
||||||
### 🍎 macOS
|
---
|
||||||
|
|
||||||
- **macOS**: `.dmg` (Universal or Apple Silicon) — **signed with an Apple Developer ID and notarized by Apple**. Gatekeeper opens it with a single click, no `xattr` workaround required.
|
# Development
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Since **v1.40.0**, macOS builds include an in-app auto-updater: click **Install now** in the update notification and the signed `.app.tar.gz` is fetched, verified against the bundled minisign public key, replaced in place, and the app relaunches — all in one step.
|
|
||||||
|
|
||||||
### 🪟 Windows
|
|
||||||
|
|
||||||
- **Windows**: `.exe` (NSIS installer)
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> **SmartScreen Note:**
|
|
||||||
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
|
||||||
|
|
||||||
1. Download and install Psysonic.
|
|
||||||
2. Open the app and enter your Subsonic/Navidrome server details (URL, Username, Password).
|
|
||||||
3. If applicable, you can provide both an external URL and a local LAN IP. Psysonic allows you to quickly toggle between them in the Settings.
|
|
||||||
4. Enjoy your music!
|
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
If you want to build Psysonic from source or contribute to the project:
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- [Node.js](https://nodejs.org/) (v18+)
|
|
||||||
- [Rust](https://www.rust-lang.org/) (v1.75+)
|
|
||||||
- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`:
|
|
||||||
- Linux: `sudo apt install cmake` / `sudo pacman -S cmake`
|
|
||||||
- macOS: `brew install cmake`
|
|
||||||
- Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake`
|
|
||||||
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/Psychotoxical/psysonic.git
|
git clone https://github.com/Psychotoxical/psysonic.git
|
||||||
cd psysonic
|
cd psysonic
|
||||||
|
|
||||||
# Install node dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Run in development mode
|
|
||||||
npm run tauri:dev
|
npm run tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
# Build for production
|
Build release:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm run tauri:build
|
npm run tauri:build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Contributing
|
---
|
||||||
|
|
||||||
Contributions are completely welcome! Whether it is translating the app into a new language, fixing a bug, or proposing a new feature.
|
# Privacy First
|
||||||
|
|
||||||
1. Fork the Project
|
* No telemetry
|
||||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
* No spyware nonsense
|
||||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
* No analytics harvesting
|
||||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
* Your library stays yours
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
## 📄 License
|
---
|
||||||
|
|
||||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
# Community
|
||||||
|
|
||||||
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
|
Join Discord, report bugs, suggest features, share themes, shape the future.
|
||||||
|
|
||||||
## 🔒 Privacy
|
---
|
||||||
|
|
||||||
Psysonic contains no telemetry or analytics. All third-party integrations (Last.fm, LRCLIB, Discord) are opt-in. See [PRIVACY.md](PRIVACY.md) for full details.
|
# License
|
||||||
|
|
||||||
|
GNU GPL v3.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Stop using boring music clients.
|
||||||
|
|
||||||
|
## Use Psysonic.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -3885,14 +3885,6 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
{
|
|
||||||
use tauri::Manager;
|
|
||||||
if let Some(win) = app.get_webview_window("main") {
|
|
||||||
win.open_devtools();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── System tray ───────────────────────────────────────────────
|
// ── System tray ───────────────────────────────────────────────
|
||||||
// Always build on startup when possible; the frontend calls toggle_tray_icon(false)
|
// Always build on startup when possible; the frontend calls toggle_tray_icon(false)
|
||||||
// immediately after load if the user has disabled the tray icon.
|
// immediately after load if the user has disabled the tray icon.
|
||||||
|
|||||||
+2
-1
@@ -309,7 +309,8 @@ export async function pingWithCredentials(
|
|||||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||||
openSubsonic: data?.openSubsonic === true,
|
openSubsonic: data?.openSubsonic === true,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { getCachedUrl } from '../utils/imageCache';
|
import { getCachedBlob } from '../utils/imageCache';
|
||||||
|
|
||||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
src: string;
|
src: string;
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delay between the consumer dropping a cached URL and the actual revoke,
|
||||||
|
// giving the DOM <img> time to finish its decode of the URL we just took
|
||||||
|
// away. 500 ms is comfortably above any realistic decode latency without
|
||||||
|
// being long enough to leak meaningful memory.
|
||||||
|
const URL_REVOKE_DELAY_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Returns an object URL for a cached image. Each call owns its own URL: it
|
||||||
|
* is created when the blob arrives and revoked (after a small grace delay)
|
||||||
|
* on cleanup. There is no shared URL pool — the previous global LRU caused
|
||||||
|
* "Failed to load resource" errors when an in-use URL got revoked because a
|
||||||
|
* different consumer pushed it out of the cache.
|
||||||
|
*
|
||||||
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
|
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
|
||||||
* blob is still resolving — useful for <img> tags so the browser starts
|
* blob is still resolving — useful for <img> tags so the browser starts
|
||||||
* loading immediately. Pass false for CSS background-image consumers that
|
* loading immediately. Pass false for CSS background-image consumers that
|
||||||
@@ -17,11 +29,22 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fetchUrl) { setResolved(''); return; }
|
if (!fetchUrl) { setResolved(''); return; }
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
let createdUrl: string | null = null;
|
||||||
setResolved('');
|
setResolved('');
|
||||||
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
|
getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => {
|
||||||
if (!controller.signal.aborted) setResolved(url);
|
if (controller.signal.aborted) return;
|
||||||
|
if (blob) {
|
||||||
|
createdUrl = URL.createObjectURL(blob);
|
||||||
|
setResolved(createdUrl);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => { controller.abort(); };
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
if (createdUrl) {
|
||||||
|
const url = createdUrl;
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), URL_REVOKE_DELAY_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [fetchUrl, cacheKey]);
|
}, [fetchUrl, cacheKey]);
|
||||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
|
||||||
import { useCachedUrl } from './CachedImage';
|
import { useCachedUrl } from './CachedImage';
|
||||||
import { getCachedUrl } from '../utils/imageCache';
|
import { getCachedBlob } from '../utils/imageCache';
|
||||||
import { extractCoverColors } from '../utils/dynamicColors';
|
import { extractCoverColors } from '../utils/dynamicColors';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
||||||
@@ -740,7 +740,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
if (!nextCoverArt) return;
|
if (!nextCoverArt) return;
|
||||||
const url = buildCoverArtUrl(nextCoverArt, 300);
|
const url = buildCoverArtUrl(nextCoverArt, 300);
|
||||||
const key = coverArtCacheKey(nextCoverArt, 300);
|
const key = coverArtCacheKey(nextCoverArt, 300);
|
||||||
getCachedUrl(url, key).catch(() => {});
|
getCachedBlob(url, key).catch(() => {});
|
||||||
}, [nextCoverArt]);
|
}, [nextCoverArt]);
|
||||||
|
|
||||||
// Lyrics settings popover state
|
// Lyrics settings popover state
|
||||||
|
|||||||
@@ -298,6 +298,12 @@ function QueuePanelHostOrSolo() {
|
|||||||
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
||||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||||
|
|
||||||
|
// When the user picks a track *from* the queue list, suppress the
|
||||||
|
// upcoming auto-scroll so their click target stays in view instead of
|
||||||
|
// the list rebasing onto the next track. Auto-advance (natural playback)
|
||||||
|
// never sets this flag, so it keeps its original "show what's next" behavior.
|
||||||
|
const suppressNextAutoScrollRef = useRef(false);
|
||||||
|
|
||||||
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
||||||
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
|
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
|
||||||
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
|
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
|
||||||
@@ -478,6 +484,10 @@ function QueuePanelHostOrSolo() {
|
|||||||
}, [enqueueAt]);
|
}, [enqueueAt]);
|
||||||
|
|
||||||
useEffect(function queueAutoScroll() {
|
useEffect(function queueAutoScroll() {
|
||||||
|
if (suppressNextAutoScrollRef.current) {
|
||||||
|
suppressNextAutoScrollRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!queueListRef.current || queueIndex < 0) return;
|
if (!queueListRef.current || queueIndex < 0) return;
|
||||||
if (activeTab !== 'queue') return;
|
if (activeTab !== 'queue') return;
|
||||||
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||||
@@ -916,7 +926,10 @@ function QueuePanelHostOrSolo() {
|
|||||||
<div
|
<div
|
||||||
data-queue-idx={idx}
|
data-queue-idx={idx}
|
||||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||||
onClick={() => playTrack(track, queue)}
|
onClick={() => {
|
||||||
|
suppressNextAutoScrollRef.current = true;
|
||||||
|
playTrack(track, queue);
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
|
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
|
||||||
|
|||||||
+35
-95
@@ -1,36 +1,10 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import { Tags } from 'lucide-react';
|
||||||
Headphones, Zap, Music2, Music, Cpu, Mic, Radio, Cloud,
|
|
||||||
Leaf, Heart, Sun, Flame, Film, Globe, BookOpen, Podcast, Star,
|
|
||||||
Tags, type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { getGenres, SubsonicGenre } from '../api/subsonic';
|
import { getGenres, SubsonicGenre } from '../api/subsonic';
|
||||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||||
|
|
||||||
function getGenreIcon(name: string): LucideIcon {
|
|
||||||
const n = name.toLowerCase();
|
|
||||||
if (/ambient|drone|new age/.test(n)) return Cloud;
|
|
||||||
if (/metal|hardcore|thrash|death|grind|doom/.test(n)) return Zap;
|
|
||||||
if (/rock/.test(n)) return Radio;
|
|
||||||
if (/jazz/.test(n)) return Music2;
|
|
||||||
if (/classical|orchestra|chamber|baroque|opera|symphon/.test(n)) return Music;
|
|
||||||
if (/electronic|techno|edm|house|trance|electro|synth/.test(n)) return Cpu;
|
|
||||||
if (/hip.?hop|rap/.test(n)) return Mic;
|
|
||||||
if (/pop/.test(n)) return Star;
|
|
||||||
if (/folk|country|bluegrass|americana/.test(n)) return Leaf;
|
|
||||||
if (/blues/.test(n)) return Music2;
|
|
||||||
if (/soul|r.?b|funk|gospel/.test(n)) return Heart;
|
|
||||||
if (/reggae|ska|dub/.test(n)) return Sun;
|
|
||||||
if (/punk/.test(n)) return Flame;
|
|
||||||
if (/soundtrack|score|ost|film|movie|cinema/.test(n)) return Film;
|
|
||||||
if (/world|latin|afro|celtic|tribal|traditional/.test(n)) return Globe;
|
|
||||||
if (/audiobook|spoken|hörbuch|speech|comedy/.test(n)) return BookOpen;
|
|
||||||
if (/podcast/.test(n)) return Podcast;
|
|
||||||
return Headphones;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CTP_COLORS = [
|
const CTP_COLORS = [
|
||||||
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
||||||
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
|
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
|
||||||
@@ -45,63 +19,39 @@ function genreColor(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SCROLL_KEY = 'genres-scroll';
|
const SCROLL_KEY = 'genres-scroll';
|
||||||
const VISIBLE_KEY = 'genres-visible';
|
const FONT_MIN_REM = 0.78;
|
||||||
const PAGE_SIZE = 60;
|
const FONT_MAX_REM = 1.7;
|
||||||
|
|
||||||
export default function Genres() {
|
export default function Genres() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>([]);
|
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [visibleCount, setVisibleCount] = useState(() => {
|
|
||||||
// Restore the previous visibleCount when navigating back from a detail
|
|
||||||
// page so scroll position lines up with rendered cards.
|
|
||||||
const saved = sessionStorage.getItem(VISIBLE_KEY);
|
|
||||||
return saved ? Math.max(PAGE_SIZE, parseInt(saved, 10)) : PAGE_SIZE;
|
|
||||||
});
|
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getGenres()
|
getGenres()
|
||||||
.then(data => setRawGenres(data))
|
.then(data => setRawGenres(data))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []); // getGenres is not folder-scoped — no dep on musicLibraryFilterVersion
|
}, []);
|
||||||
|
|
||||||
// Memoised sort — without this the page re-sorted 500+ entries on every
|
|
||||||
// unrelated re-render (e.g. theme change, sidebar toggle).
|
|
||||||
const genres = useMemo(
|
const genres = useMemo(
|
||||||
() => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount),
|
() => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount),
|
||||||
[rawGenres],
|
[rawGenres],
|
||||||
);
|
);
|
||||||
|
|
||||||
const visible = useMemo(() => genres.slice(0, visibleCount), [genres, visibleCount]);
|
// Log-scale font sizing — flattens the long tail (a 1000-album genre and a
|
||||||
const hasMore = visibleCount < genres.length;
|
// 50-album genre look distinct, but a 1-album genre still has a readable size).
|
||||||
|
const maxLog = useMemo(() => {
|
||||||
|
if (genres.length === 0) return 1;
|
||||||
|
return Math.log(Math.max(2, genres[0].albumCount));
|
||||||
|
}, [genres]);
|
||||||
|
|
||||||
// Infinite scroll — render the next batch when the user is ~1.5 screens
|
|
||||||
// away from the sentinel, so the rest of the watermarks never block first
|
|
||||||
// paint of the page.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasMore) return;
|
|
||||||
const root = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
entries => { if (entries[0].isIntersecting) setVisibleCount(c => c + PAGE_SIZE); },
|
|
||||||
{
|
|
||||||
root: root instanceof HTMLElement ? root : null,
|
|
||||||
rootMargin: '1500px',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (observerTarget.current) observer.observe(observerTarget.current);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [hasMore]);
|
|
||||||
|
|
||||||
// Restore scroll position after genres are rendered
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || genres.length === 0) return;
|
if (loading || genres.length === 0) return;
|
||||||
const saved = sessionStorage.getItem(SCROLL_KEY);
|
const saved = sessionStorage.getItem(SCROLL_KEY);
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
const pos = parseInt(saved, 10);
|
const pos = parseInt(saved, 10);
|
||||||
sessionStorage.removeItem(SCROLL_KEY);
|
sessionStorage.removeItem(SCROLL_KEY);
|
||||||
sessionStorage.removeItem(VISIBLE_KEY);
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||||
if (el) el.scrollTop = pos;
|
if (el) el.scrollTop = pos;
|
||||||
@@ -110,10 +60,7 @@ export default function Genres() {
|
|||||||
|
|
||||||
const handleGenreClick = (genreValue: string) => {
|
const handleGenreClick = (genreValue: string) => {
|
||||||
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||||
if (el) {
|
if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
|
||||||
sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
|
|
||||||
sessionStorage.setItem(VISIBLE_KEY, String(visibleCount));
|
|
||||||
}
|
|
||||||
navigate(`/genres/${encodeURIComponent(genreValue)}`);
|
navigate(`/genres/${encodeURIComponent(genreValue)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,36 +79,29 @@ export default function Genres() {
|
|||||||
{loading && <p className="loading-text">{t('genres.loading')}</p>}
|
{loading && <p className="loading-text">{t('genres.loading')}</p>}
|
||||||
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
|
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
|
||||||
|
|
||||||
{!loading && visible.length > 0 && (
|
{!loading && genres.length > 0 && (
|
||||||
<>
|
<div className="genre-cloud">
|
||||||
<div className="album-grid-wrap">
|
{genres.map(genre => {
|
||||||
{visible.map(genre => {
|
const ratio = Math.log(Math.max(2, genre.albumCount)) / maxLog;
|
||||||
const Icon = getGenreIcon(genre.value);
|
const fontRem = FONT_MIN_REM + ratio * (FONT_MAX_REM - FONT_MIN_REM);
|
||||||
const color = genreColor(genre.value);
|
const color = genreColor(genre.value);
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={genre.value}
|
key={genre.value}
|
||||||
className="genre-card"
|
type="button"
|
||||||
style={{ '--genre-color': color } as React.CSSProperties}
|
className="genre-pill"
|
||||||
onClick={() => handleGenreClick(genre.value)}
|
style={{
|
||||||
role="button"
|
'--genre-color': color,
|
||||||
tabIndex={0}
|
fontSize: `${fontRem.toFixed(3)}rem`,
|
||||||
onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)}
|
} as React.CSSProperties}
|
||||||
data-tooltip={genre.value}
|
onClick={() => handleGenreClick(genre.value)}
|
||||||
>
|
data-tooltip={t('genres.albumCount', { count: genre.albumCount })}
|
||||||
<div className="genre-card-watermark">
|
>
|
||||||
<Icon size={80} strokeWidth={1.2} />
|
{genre.value}
|
||||||
</div>
|
</button>
|
||||||
<p className="genre-card-name">{genre.value}</p>
|
);
|
||||||
<p className="genre-card-count">
|
})}
|
||||||
{t('genres.albumCount', { count: genre.albumCount })}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{hasMore && <div ref={observerTarget} style={{ height: 1 }} />}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+29
-43
@@ -7144,55 +7144,41 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ Genre Cards ─ */
|
/* ─ Genre Tag Cloud ─ */
|
||||||
.genre-card {
|
.genre-cloud {
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
background:
|
gap: 0.55rem 0.6rem;
|
||||||
linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 45%, transparent 70%),
|
padding-bottom: 1rem;
|
||||||
var(--genre-color, var(--accent));
|
}
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
|
.genre-pill {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--genre-color, var(--accent)) 22%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--genre-color, var(--accent)) 6%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.28em 0.78em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, transform 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genre-card:hover {
|
.genre-pill:hover {
|
||||||
transform: translateY(-3px) scale(1.02);
|
background: color-mix(in srgb, var(--genre-color, var(--accent)) 14%, transparent);
|
||||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
|
border-color: color-mix(in srgb, var(--genre-color, var(--accent)) 45%, transparent);
|
||||||
|
color: var(--genre-color, var(--accent));
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.genre-card-watermark {
|
.genre-pill:focus-visible {
|
||||||
position: absolute;
|
outline: 2px solid var(--genre-color, var(--accent));
|
||||||
top: 50%;
|
outline-offset: 2px;
|
||||||
right: -10px;
|
|
||||||
transform: translateY(-55%);
|
|
||||||
opacity: 0.22;
|
|
||||||
color: #fff;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genre-card-name {
|
|
||||||
font-size: 0.86rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.25;
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genre-card-count {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
margin: 0.2rem 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ Random Landing ─ */
|
/* ─ Random Landing ─ */
|
||||||
|
|||||||
+44
-74
@@ -3,22 +3,18 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
const DB_NAME = 'psysonic-img-cache';
|
const DB_NAME = 'psysonic-img-cache';
|
||||||
const STORE_NAME = 'images';
|
const STORE_NAME = 'images';
|
||||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
const MAX_MEMORY_CACHE = 150; // max object URLs kept in RAM
|
const MAX_BLOB_CACHE = 200; // hot in-memory blob entries (LRU)
|
||||||
const MAX_CONCURRENT_FETCHES = 5;
|
const MAX_CONCURRENT_FETCHES = 5;
|
||||||
|
|
||||||
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
|
// In-memory blob cache: cacheKey → Blob (insertion-order = LRU approximation).
|
||||||
const objectUrlCache = new Map<string, string>();
|
// Only the Map entry is dropped on overflow — the underlying Blob is freed by
|
||||||
|
// the GC once no <img>/<canvas>/object URL still references it. Each consumer
|
||||||
|
// is responsible for its own URL.createObjectURL lifecycle.
|
||||||
|
const blobCache = new Map<string, Blob>();
|
||||||
|
|
||||||
// Concurrency limiter for network fetches.
|
|
||||||
// Each queue entry is a resolver that signals "slot acquired".
|
|
||||||
let activeFetches = 0;
|
let activeFetches = 0;
|
||||||
const fetchQueue: Array<() => void> = [];
|
const fetchQueue: Array<() => void> = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquires a fetch slot. Returns true if a slot was granted, false if the
|
|
||||||
* provided AbortSignal fired while the call was waiting in the queue (in that
|
|
||||||
* case no slot is held and the caller must NOT call releaseFetchSlot).
|
|
||||||
*/
|
|
||||||
function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
|
function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
|
||||||
if (signal?.aborted) return Promise.resolve(false);
|
if (signal?.aborted) return Promise.resolve(false);
|
||||||
if (activeFetches < MAX_CONCURRENT_FETCHES) {
|
if (activeFetches < MAX_CONCURRENT_FETCHES) {
|
||||||
@@ -31,7 +27,6 @@ function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
|
|||||||
resolve(true);
|
resolve(true);
|
||||||
};
|
};
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
// Remove from queue without consuming a slot — no releaseFetchSlot needed.
|
|
||||||
const idx = fetchQueue.indexOf(onGrant);
|
const idx = fetchQueue.indexOf(onGrant);
|
||||||
if (idx !== -1) fetchQueue.splice(idx, 1);
|
if (idx !== -1) fetchQueue.splice(idx, 1);
|
||||||
resolve(false);
|
resolve(false);
|
||||||
@@ -47,12 +42,13 @@ function releaseFetchSlot(): void {
|
|||||||
if (next) { activeFetches++; next(); }
|
if (next) { activeFetches++; next(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function evictMemoryIfNeeded(): void {
|
function rememberBlob(key: string, blob: Blob): void {
|
||||||
while (objectUrlCache.size > MAX_MEMORY_CACHE) {
|
blobCache.delete(key); // re-insert at end → marks as recently used
|
||||||
const oldestKey = objectUrlCache.keys().next().value;
|
blobCache.set(key, blob);
|
||||||
if (!oldestKey) break;
|
while (blobCache.size > MAX_BLOB_CACHE) {
|
||||||
URL.revokeObjectURL(objectUrlCache.get(oldestKey)!);
|
const oldest = blobCache.keys().next().value;
|
||||||
objectUrlCache.delete(oldestKey);
|
if (!oldest) break;
|
||||||
|
blobCache.delete(oldest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +75,7 @@ function openDB(): Promise<IDBDatabase> {
|
|||||||
return dbPromise;
|
return dbPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBlob(key: string): Promise<Blob | null> {
|
async function getBlobFromIDB(key: string): Promise<Blob | null> {
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
@@ -95,7 +91,6 @@ async function getBlob(key: string): Promise<Blob | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Evicts oldest IDB entries until total blob size is below maxBytes. Fire-and-forget. */
|
|
||||||
async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
|
async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
@@ -116,7 +111,6 @@ async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
|
|||||||
let total = entries.reduce((acc, e) => acc + e.size, 0);
|
let total = entries.reduce((acc, e) => acc + e.size, 0);
|
||||||
if (total <= maxBytes) return;
|
if (total <= maxBytes) return;
|
||||||
|
|
||||||
// Oldest first
|
|
||||||
entries.sort((a, b) => a.timestamp - b.timestamp);
|
entries.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
const tx = database.transaction(STORE_NAME, 'readwrite');
|
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||||
@@ -124,12 +118,7 @@ async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (total <= maxBytes) break;
|
if (total <= maxBytes) break;
|
||||||
store.delete(entry.key);
|
store.delete(entry.key);
|
||||||
// Also purge from memory cache
|
blobCache.delete(entry.key);
|
||||||
const objUrl = objectUrlCache.get(entry.key);
|
|
||||||
if (objUrl) {
|
|
||||||
URL.revokeObjectURL(objUrl);
|
|
||||||
objectUrlCache.delete(entry.key);
|
|
||||||
}
|
|
||||||
total -= entry.size;
|
total -= entry.size;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -146,7 +135,6 @@ async function putBlob(key: string, blob: Blob): Promise<void> {
|
|||||||
tx.oncomplete = () => resolve();
|
tx.oncomplete = () => resolve();
|
||||||
tx.onerror = () => resolve();
|
tx.onerror = () => resolve();
|
||||||
});
|
});
|
||||||
// Enforce disk limit after write (fire-and-forget)
|
|
||||||
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
|
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
|
||||||
evictDiskIfNeeded(maxBytes);
|
evictDiskIfNeeded(maxBytes);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -154,7 +142,6 @@ async function putBlob(key: string, blob: Blob): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the total size in bytes of all blobs stored in IndexedDB. */
|
|
||||||
export async function getImageCacheSize(): Promise<number> {
|
export async function getImageCacheSize(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
@@ -171,13 +158,8 @@ export async function getImageCacheSize(): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes a single cache entry from both in-memory and IndexedDB caches. */
|
|
||||||
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
|
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
|
||||||
const existing = objectUrlCache.get(cacheKey);
|
blobCache.delete(cacheKey);
|
||||||
if (existing) {
|
|
||||||
URL.revokeObjectURL(existing);
|
|
||||||
objectUrlCache.delete(cacheKey);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
@@ -191,22 +173,14 @@ export async function invalidateCacheKey(cacheKey: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidates all cached sizes for a given cover art entity (artist, radio, playlist, album).
|
|
||||||
* Call this after uploading or deleting a cover image so the UI re-fetches from the server.
|
|
||||||
*/
|
|
||||||
export async function invalidateCoverArt(entityId: string): Promise<void> {
|
export async function invalidateCoverArt(entityId: string): Promise<void> {
|
||||||
const serverId = useAuthStore.getState().getActiveServer()?.id ?? '_';
|
const serverId = useAuthStore.getState().getActiveServer()?.id ?? '_';
|
||||||
const sizes = [40, 64, 128, 200, 256, 300, 500, 2000];
|
const sizes = [40, 64, 128, 200, 256, 300, 500, 2000];
|
||||||
await Promise.all(sizes.map(size => invalidateCacheKey(`${serverId}:cover:${entityId}:${size}`)));
|
await Promise.all(sizes.map(size => invalidateCacheKey(`${serverId}:cover:${entityId}:${size}`)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */
|
|
||||||
export async function clearImageCache(): Promise<void> {
|
export async function clearImageCache(): Promise<void> {
|
||||||
for (const url of objectUrlCache.values()) {
|
blobCache.clear();
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
objectUrlCache.clear();
|
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
@@ -221,49 +195,45 @@ export async function clearImageCache(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a cached object URL for an image.
|
* Returns the cached Blob for an image, fetching it if necessary. Callers own
|
||||||
|
* any object URL they create from the returned blob and must revoke it when
|
||||||
|
* done — there is no shared URL pool.
|
||||||
|
*
|
||||||
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
||||||
* @param cacheKey A stable key that identifies the image across sessions.
|
* @param cacheKey A stable key that identifies the image across sessions.
|
||||||
* @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches
|
* @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches.
|
||||||
* so navigating away does not leave zombie fetches draining I/O.
|
|
||||||
*/
|
*/
|
||||||
export async function getCachedUrl(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise<string> {
|
export async function getCachedBlob(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise<Blob | null> {
|
||||||
if (!fetchUrl || signal?.aborted) return '';
|
if (!fetchUrl || signal?.aborted) return null;
|
||||||
|
|
||||||
// 1. In-memory hit (same session)
|
const memHit = blobCache.get(cacheKey);
|
||||||
const existing = objectUrlCache.get(cacheKey);
|
if (memHit) {
|
||||||
if (existing) return existing;
|
rememberBlob(cacheKey, memHit); // refresh LRU position
|
||||||
|
return memHit;
|
||||||
// 2. IndexedDB hit (persisted from previous session)
|
}
|
||||||
const blob = await getBlob(cacheKey);
|
|
||||||
if (signal?.aborted) return '';
|
const idbHit = await getBlobFromIDB(cacheKey);
|
||||||
if (blob) {
|
if (signal?.aborted) return null;
|
||||||
const objUrl = URL.createObjectURL(blob);
|
if (idbHit) {
|
||||||
objectUrlCache.set(cacheKey, objUrl);
|
rememberBlob(cacheKey, idbHit);
|
||||||
evictMemoryIfNeeded();
|
return idbHit;
|
||||||
return objUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL.
|
|
||||||
// acquireFetchSlot returns false (without holding a slot) when aborted in queue.
|
|
||||||
const acquired = await acquireFetchSlot(signal);
|
const acquired = await acquireFetchSlot(signal);
|
||||||
if (!acquired || signal?.aborted) {
|
if (!acquired || signal?.aborted) {
|
||||||
if (acquired) releaseFetchSlot();
|
if (acquired) releaseFetchSlot();
|
||||||
return '';
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(fetchUrl);
|
const resp = await fetch(fetchUrl, { signal });
|
||||||
if (!resp.ok) return fetchUrl;
|
if (!resp.ok) return null;
|
||||||
const newBlob = await resp.blob();
|
const newBlob = await resp.blob();
|
||||||
if (signal?.aborted) return '';
|
if (signal?.aborted) return null;
|
||||||
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
|
putBlob(cacheKey, newBlob); // fire-and-forget
|
||||||
const objUrl = URL.createObjectURL(newBlob);
|
rememberBlob(cacheKey, newBlob);
|
||||||
objectUrlCache.set(cacheKey, objUrl);
|
return newBlob;
|
||||||
evictMemoryIfNeeded();
|
} catch {
|
||||||
return objUrl;
|
return null;
|
||||||
} catch (e) {
|
|
||||||
// AbortError → return '' (component is gone). Other errors → return raw URL.
|
|
||||||
return e instanceof DOMException && e.name === 'AbortError' ? '' : fetchUrl;
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseFetchSlot();
|
releaseFetchSlot();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user