Compare commits
6 Commits
app-v1.4.0
..
v1.4.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 18199a1f8a | |||
| d4e44199e9 | |||
| 9b4eb0982c | |||
| 8ec642f368 | |||
| 73f836b2ee | |||
| af18aef42a |
@@ -22,20 +22,39 @@ jobs:
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
# Extract the block between ## [VERSION] and the next ## heading
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
# Store multiline output
|
||||
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF" >> $GITHUB_OUTPUT
|
||||
echo "$BODY" >> $GITHUB_OUTPUT
|
||||
echo "$EOF" >> $GITHUB_OUTPUT
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
|
||||
with:
|
||||
script: |
|
||||
const tag = `app-v${process.env.PACKAGE_VERSION}`;
|
||||
const body = process.env.CHANGELOG_BODY || 'See the assets to download this version and install.';
|
||||
try {
|
||||
const { data } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: data.id,
|
||||
body,
|
||||
});
|
||||
return data.id;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
@@ -45,7 +64,7 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body: 'See the assets to download this version and install.',
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
});
|
||||
@@ -89,7 +108,7 @@ jobs:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -98,15 +117,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev \
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-libav
|
||||
|
||||
- name: install linuxdeploy gstreamer plugin
|
||||
run: |
|
||||
wget -q "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
|
||||
-O /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
libasound2-dev
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
@@ -120,21 +131,7 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: build
|
||||
run: npm run tauri:build
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
|
||||
- name: patch AppImage AppRun
|
||||
run: |
|
||||
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
|
||||
echo "Patching: $APPIMAGE"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
|
||||
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
|
||||
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
|
||||
-O appimagetool
|
||||
chmod +x appimagetool
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool squashfs-root "$APPIMAGE"
|
||||
rm -rf squashfs-root appimagetool
|
||||
run: npm run tauri:build -- --bundles deb,rpm
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
@@ -142,5 +139,5 @@ jobs:
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.AppImage" -not -name "*.tar.gz" -o -name "*.deb" -o -name "*.rpm" \) \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
@@ -5,6 +5,75 @@ 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.4.5] - 2026-03-17
|
||||
|
||||
### Changed
|
||||
|
||||
#### Artist Pages — External Links
|
||||
- Last.fm and Wikipedia buttons now open in the **system browser** instead of an in-app window. The button label temporarily changes to "Opened in browser" / "Im Browser geöffnet" for 2.5 seconds as visual confirmation.
|
||||
|
||||
#### Queue Panel
|
||||
- **Release year** added to the now-playing meta box, shown below the album name (when available).
|
||||
- **Cover art enlarged** from 72 × 72 px to 90 × 90 px, aligned to the top of the meta block so it lines up with the song title.
|
||||
- **Default width increased** from 300 px to 340 px.
|
||||
|
||||
## [1.4.4] - 2026-03-17
|
||||
|
||||
### Added
|
||||
|
||||
#### AUR Package
|
||||
- Psysonic is now available on the **Arch User Repository** — Arch and CachyOS users can install via `yay -S psysonic` or `paru -S psysonic`. Builds from source using the system's own WebKitGTK, avoiding the EGL/Mesa compatibility issues that affected the AppImage on modern distros.
|
||||
|
||||
### Changed
|
||||
|
||||
#### App Icon
|
||||
- New app icon across all platforms (Windows, macOS, Linux, Android, iOS).
|
||||
|
||||
#### Linux Distribution
|
||||
- **AppImage removed**: The AppImage was fundamentally incompatible with non-Ubuntu distros (Arch, Fedora) due to bundled WebKitGTK conflicting with the system's Mesa/EGL. Linux users should use the `.deb` (Ubuntu/Debian), `.rpm` (Fedora/RHEL), or the new AUR package (Arch/CachyOS).
|
||||
|
||||
## [1.4.3] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Random Mix — Genre Mix
|
||||
- **Second "Play All" button removed**: The genre mix section had a redundant play button below the super-genre selector. The top-right button is now context-aware — it plays the genre mix when one is active, otherwise the regular mix.
|
||||
- **"Play All" disabled during genre mix loading**: The button now stays grayed out with a live progress counter (`n / 50`) until all songs are fully loaded. Clicking while the list was still building sent only the songs loaded so far.
|
||||
- **Over-fetching fixed**: Genre mix previously fetched up to 100+ songs and sliced to 50 at the end. Now the matched genre list is capped at 50 (randomly sampled when more match) so the total fetch stays close to 50 with no wasted server I/O.
|
||||
- **Regular mix cache-busting**: `getRandomSongs` requests now include a timestamp parameter, preventing browser/axios from returning a cached response and showing the same list on every remix.
|
||||
- **Display/state mismatch on remix**: Clicking "Mischen" now clears the current list immediately, ensuring the spinner is shown and the displayed songs always match what "Play All" would send.
|
||||
|
||||
#### Queue Panel
|
||||
- **Hover highlight lost on right-click**: Queue items now retain their hover highlight while a context menu is open for them (`.context-active` CSS class).
|
||||
- **Song count and total duration**: The queue header now shows the number of tracks and total runtime below the title (e.g. `12 tracks · 47:32`).
|
||||
|
||||
#### Context Menu
|
||||
- **"Favorite" option added for queue items**: Right-clicking a queue item now includes a "Favorite" option, consistent with the song context menu.
|
||||
|
||||
## [1.4.2] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Linux AppImage — Modern Distro Compatibility
|
||||
- **Build upgraded to Ubuntu 24.04**: The AppImage was previously built on Ubuntu 22.04 with WebKitGTK 2.36. On modern distros (CachyOS, Arch, etc.) with Mesa 25.x, `eglGetDisplay(EGL_DEFAULT_DISPLAY)` returns `EGL_BAD_PARAMETER` and aborts immediately because newer Mesa no longer accepts implicit platform detection. Building on Ubuntu 24.04 bundles WebKitGTK 2.44 which uses the correct `eglGetPlatformDisplay` API.
|
||||
- **`EGL_PLATFORM=x11` added to AppRun**: Additional safeguard that explicitly tells Mesa's EGL loader to use the X11 platform when the app is running under XWayland.
|
||||
|
||||
#### Shell — Update Link
|
||||
- `shell:allow-open` capability now includes a URL scope (`https://**`), fixing the update toast link that silently did nothing in Tauri v2 without an explicit allow-list.
|
||||
|
||||
## [1.4.1] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Random Albums — Performance & Memory
|
||||
- **Auto-refresh removed**: The 30-second auto-cycle timer caused 10 React state updates/second (progress bar interval) and a burst of 30 concurrent image fetches on every tick, eventually making the whole app unresponsive. The page now loads once on mount; use the manual refresh button to get a new selection.
|
||||
- **Concurrent fetch limit**: Image fetches are now capped at 5 simultaneous network requests (was unlimited — 30 at once on every refresh).
|
||||
- **Object URL memory leak**: The in-memory image cache now caps at 150 entries and revokes old object URLs via `URL.revokeObjectURL()` when evicting. Previously, object URLs accumulated without bound across the entire session.
|
||||
- **Dangling state updates**: `useCachedUrl` now uses a cancellation flag — if a component unmounts while a fetch is in flight (e.g. during a grid refresh), the resolved URL is discarded instead of calling `setState` on an unmounted component.
|
||||
|
||||
#### i18n
|
||||
- Page title "Neueste" on the New Releases page was hardcoded German. Now uses `t('sidebar.newReleases')`.
|
||||
|
||||
## [1.4.0] - 2026-03-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What is Psysonic
|
||||
|
||||
A desktop music player (Tauri v2 + React 18 + TypeScript) for Subsonic API-compatible servers (Navidrome, Gonic, etc.). UI is styled after the Catppuccin aesthetic with glassmorphism effects.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Dev mode (Linux — uses X11 backend to avoid WebKit compositing issues)
|
||||
npm run tauri:dev
|
||||
# Equivalent to: GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev
|
||||
|
||||
# Production build
|
||||
npm run tauri:build
|
||||
|
||||
# Frontend-only dev server (no Tauri shell)
|
||||
npm run dev
|
||||
|
||||
# Type-check + bundle frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
- **Frontend**: React 18 + TypeScript + Vite, served inside a Tauri WebView
|
||||
- **Backend**: Rust (Tauri v2) — handles tray icon, media key shortcuts, `exit_app` command, and the full audio engine
|
||||
- **State**: Zustand stores (no Redux)
|
||||
- **Audio**: Rust/rodio engine (`src-tauri/src/audio.rs`) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section.
|
||||
- **API**: All server communication goes through `src/api/subsonic.ts` — a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt)
|
||||
- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English + German)
|
||||
|
||||
### Key files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/api/subsonic.ts` | All Subsonic REST calls + `buildStreamUrl` / `buildCoverArtUrl` / `buildDownloadUrl` helpers. Also exports `pingWithCredentials()` and `coverArtCacheKey()`. `getRandomSongs` includes a `_t` timestamp param to prevent browser/axios caching. |
|
||||
| `src/utils/imageCache.ts` | IndexedDB image cache (30-day TTL) + in-memory object URL Map. `getCachedUrl(fetchUrl, cacheKey)` is the main entry point. Capped at 150 entries with LRU eviction + `URL.revokeObjectURL`. Max 5 concurrent fetches. |
|
||||
| `src/components/CachedImage.tsx` | Drop-in `<img>` replacement that resolves via the image cache. Also exports `useCachedUrl(fetchUrl, cacheKey)` hook for CSS background-image use cases. Uses cancellation flag to prevent setState on unmounted components. |
|
||||
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
|
||||
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50%, server queue sync (debounced 1.5s). Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
|
||||
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
|
||||
| `src/store/themeStore.ts` | Theme selection (8 themes), applied as `data-theme` on `<html>` |
|
||||
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
|
||||
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions) |
|
||||
| `src/i18n.ts` | All translations (en + de) inline. Language persisted in `localStorage('psysonic_language')`. |
|
||||
| `src/components/Sidebar.tsx` | Sidebar nav + `UpdateToast` component. On mount (1.5s delay) fetches `https://api.github.com/repos/Psychotoxical/psysonic/releases/latest`, compares `tag_name` against `version` imported directly from `package.json` (build-time constant — more reliable than `getVersion()` from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. |
|
||||
| `src/components/AlbumHeader.tsx` | Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. |
|
||||
| `src/components/AlbumTrackList.tsx` | Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. |
|
||||
| `src/components/QueuePanel.tsx` | Queue sidebar. Shows song count + total duration below title. Items get `.context-active` class while their context menu is open. |
|
||||
| `packages/aur/PKGBUILD` | AUR package definition for Arch/CachyOS. Installs a wrapper script at `/usr/bin/psysonic` that sets `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` before launching the binary. |
|
||||
|
||||
### Multi-server support
|
||||
`authStore` holds a `ServerProfile[]` array and an `activeServerId`. The `ServerProfile` shape is:
|
||||
```typescript
|
||||
interface ServerProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
```
|
||||
Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL.
|
||||
|
||||
### Login / connection flow
|
||||
- Connection is tested with `pingWithCredentials(url, username, password)` from `src/api/subsonic.ts` **before** writing anything to the store.
|
||||
- Only after a successful ping: `addServer()` + `setActiveServer()` + `setLoggedIn(true)`.
|
||||
- `RequireAuth` in `App.tsx` redirects to `/login` if `!isLoggedIn || !activeServerId || servers.length === 0`.
|
||||
- **Do not** call `addServer()` before verifying the connection — this avoids a rehydration race condition with Zustand's async storage.
|
||||
|
||||
### Auth salt security
|
||||
`secureRandomSalt()` in `subsonic.ts` uses `crypto.getRandomValues()` (not `Math.random()`) for all token auth salts.
|
||||
|
||||
### Image caching
|
||||
`buildCoverArtUrl()` generates a new ephemeral URL on every call (new salt) — the browser cache is useless. All cover art and artist images are cached via:
|
||||
- `coverArtCacheKey(id, size)` — stable key: `${serverId}:cover:${id}:${size}`
|
||||
- `CachedImage` component or `useCachedUrl` hook — resolve via IndexedDB, fall back to direct URL
|
||||
- Use `useCachedUrl` (not `CachedImage`) for CSS `background-image` properties
|
||||
- **Gotcha**: `useCachedUrl` / hooks from `CachedImage.tsx` must be called unconditionally, before any early `return` in the component. Derive inputs from nullable state (e.g. `album?.album.coverArt`) rather than placing the hook after guard returns.
|
||||
|
||||
### Data flow
|
||||
1. `authStore.getBaseUrl()` returns the active server's URL
|
||||
2. `subsonic.ts` calls `useAuthStore.getState()` directly (not hooks) to build each request
|
||||
3. `playerStore.playTrack()` calls `invoke('audio_play', { url, volume, durationHint })`, calls `reportNowPlaying`, listens for `audio:progress` / `audio:ended` events, triggers scrobble at 50% via `scrobbleSong`, and debounces server queue sync
|
||||
4. Tauri events (`media:play-pause`, `tray:play-pause`, etc.) are bridged to store actions in `TauriEventBridge` inside `App.tsx`
|
||||
5. On cold start (app restart): if `currentTrack` is in localStorage, `resume()` calls `audio_play` + seeks to saved `currentTime`
|
||||
|
||||
### Adding a new page
|
||||
1. Create `src/pages/MyPage.tsx`
|
||||
2. Add a `<Route>` in `AppShell` in `src/App.tsx`
|
||||
3. Add a sidebar link in `src/components/Sidebar.tsx`
|
||||
4. Add i18n keys to both `enTranslation` and `deTranslation` in `src/i18n.ts`
|
||||
|
||||
### Adding a new Subsonic API call
|
||||
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
|
||||
|
||||
### Themes
|
||||
8 themes are available, selectable in Settings. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
|
||||
|
||||
| Theme | Style | Accent |
|
||||
|---|---|---|
|
||||
| `mocha` | Catppuccin dark | Mauve |
|
||||
| `macchiato` | Catppuccin medium-dark | Mauve |
|
||||
| `frappe` | Catppuccin medium | Mauve |
|
||||
| `latte` | Catppuccin light | Mauve |
|
||||
| `nord` | Nord Polar Night dark | Frost `#88c0d0` |
|
||||
| `nord-snowstorm` | Nord Snow Storm light | Deep-Blue `#5e81ac` |
|
||||
| `nord-frost` | Nord deep ocean blue | Frost `#88c0d0` |
|
||||
| `nord-aurora` | Nord Polar Night + aurora | Purple `#b48ead` |
|
||||
|
||||
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
|
||||
|
||||
### Artists page — initial avatars
|
||||
Artist images are intentionally **not loaded** on the Artists overview page (grid + list view) to avoid slow server disk I/O on large libraries. Instead, each artist gets a colour-coded initial avatar: first letter of the name (skipping leading punctuation/numbers), colour deterministically hashed from the name using Catppuccin palette variables. Artist images are still loaded on the individual ArtistDetail page (cached via IndexedDB).
|
||||
|
||||
### Artist cards
|
||||
`ArtistCardLocal` uses the same structure as `AlbumCard`: no padding, full-width square cover via `aspect-ratio: 1`, info below. Both use `flex: 0 0 clamp(140px, 15vw, 180px)` inside `.album-grid` so they stay the same size as album cards.
|
||||
|
||||
### NowPlayingDropdown — Live button
|
||||
`src/components/NowPlayingDropdown.tsx` polls `getNowPlaying` every 10 seconds in the background. Navidrome keeps stale "now playing" entries for several minutes after playback stops. To fix this: entries belonging to the current user (`ownUsername`) are filtered by the **local `isPlaying` state** from `playerStore` — so the badge disappears instantly when the user pauses or stops, without waiting for the server to clear the entry. Clicking an entry navigates to the album page (`/album/:albumId`) if `stream.albumId` is available.
|
||||
|
||||
### i18n
|
||||
All German strings live exclusively in `src/i18n.ts` — never hardcode German in `.tsx` files. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`.
|
||||
|
||||
**German terminology**: "Queue" is always "Warteschlange" in German — never leave "Queue" untranslated in DE strings.
|
||||
|
||||
### Tauri capabilities
|
||||
Tauri v2 capability configs live in `src-tauri/capabilities/`. Schema is auto-generated into `src-tauri/gen/schemas/`. Modify capabilities there when adding new Tauri plugins or IPC commands.
|
||||
|
||||
## Release / CI
|
||||
|
||||
Releases are triggered by pushing a `v*` tag. The GitHub Actions workflow (`.github/workflows/release.yml`) builds for macOS (arm64 + x86_64), Linux (Ubuntu 24.04 → deb + rpm), and Windows.
|
||||
|
||||
The workflow is split into three jobs: `create-release` (creates the GitHub Release with changelog body from CHANGELOG.md), `build-macos-windows` (macOS + Windows via tauri-action), and `build-linux` (Ubuntu 24.04, manual, builds only deb + rpm via `--bundles deb,rpm`).
|
||||
|
||||
**AppImage is no longer built.** The AppImage was fundamentally incompatible with non-Ubuntu distros (Arch, Fedora) due to the bundled WebKitGTK conflicting with the system's Mesa/EGL stack.
|
||||
|
||||
### Linux distribution channels
|
||||
| Distro family | Package |
|
||||
|---|---|
|
||||
| Ubuntu / Debian | `.deb` from GitHub Releases |
|
||||
| Fedora / RHEL | `.rpm` from GitHub Releases |
|
||||
| Arch / CachyOS | AUR: `yay -S psysonic` or `paru -S psysonic` |
|
||||
|
||||
### AUR package (`packages/aur/PKGBUILD`)
|
||||
- Maintained at `aur.archlinux.org/packages/psysonic` (account: Psychotoxical)
|
||||
- Builds from source using the system's own WebKitGTK — no bundled libs, no EGL issues
|
||||
- Installs a wrapper script at `/usr/bin/psysonic` setting `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1`
|
||||
- **When releasing**: bump `pkgver` in `packages/aur/PKGBUILD`, copy to `~/aur-psysonic/`, run `makepkg --printsrcinfo > .SRCINFO`, commit and push to AUR remote
|
||||
|
||||
## Notes
|
||||
- Media key shortcuts (`MediaPlayPause`, etc.) are **not registered on Linux** (see `#[cfg(not(target_os = "linux"))]` in `lib.rs`). Spacebar is the keyboard shortcut for play/pause instead.
|
||||
- `playerStore` persists `volume`, `repeatMode`, `currentTrack`, `queue`, `queueIndex`, and `currentTime` to `localStorage` via Zustand `partialize`. The audio engine state is runtime-only (Rust side).
|
||||
- Auth data is persisted via **`localStorage`** (synchronous Zustand storage). Do **not** switch to `@tauri-apps/plugin-store` for `authStore` — async storage causes a rehydration race condition where `getActiveServer()` returns `undefined` before state is restored.
|
||||
- `tauri.conf.json` CSP is set to `null` — a stricter CSP breaks HTTP requests in WebKitGTK on Linux.
|
||||
- App logo: `public/logo.png` (used in login page and elsewhere). All platform icons generated from this via `npx tauri icon public/logo.png`.
|
||||
- **Audio engine (Rust/rodio)**: `audio_play` downloads the full track via reqwest, decodes with symphonia/rodio `Decoder`, appends to a `Sink`. A generation counter (`AtomicU64`) cancels in-flight downloads when the user skips. Progress is tracked via wall-clock (`seek_offset + elapsed`) clamped to `duration_secs` — **not** `sink.empty()` (unreliable in rodio 0.19). `audio:ended` fires after 2 consecutive ticks where `pos >= dur - 1.0s`. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert.
|
||||
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` and updates `seek_offset` + `play_started`.
|
||||
- **Cold-start resume**: `resume()` checks `isAudioPaused` flag. If true (warm resume), calls `audio_resume`. If false (cold start after restart), calls `audio_play` with saved URL then `audio_seek` to saved `currentTime`. Position preference: server queue position > 0 → use server; otherwise use localStorage value.
|
||||
- **Drag-and-drop**: All drag sources use `dataTransfer.setData('text/plain', ...)` — WebView2 (Windows) does not support custom MIME types like `application/json`. Queue reordering calculates the **drop target index from `e.clientY`** at drop time (iterates `[data-queue-idx]` elements, picks the first whose midpoint is below the cursor). `fromIdx` comes from `dataTransfer` (set in `dragstart`, always reliable). `onDragEnd` clears refs synchronously. All drops are handled by the `<aside>` container — no `onDrop` on individual queue items.
|
||||
- **Drag-and-drop cursor (Linux)**: WebKitGTK does not honour `dropEffect` for cursor display — the cursor may show as forbidden or no indicator depending on the compositor (KDE Plasma vs GNOME). DnD works correctly regardless. This is a known WebKitGTK limitation, not fixable from web content.
|
||||
- **Fullscreen Player ("Ambient Stage")**: Single centered column — no tracklist. Background uses the artist's `largeImageUrl` from `getArtistInfo()` (falls back to cover art). Three CSS-animated color orbs (`--ctp-mauve`, `--ctp-blue`, `--ctp-lavender`) drift behind everything. Cover has a slow breathing animation (`cover-breathe` keyframe). Long song titles scroll as a marquee (`MarqueeTitle` component — measures overflow via `getBoundingClientRect` + `ResizeObserver`, animates via CSS custom property `--scroll-amount`). `Track.artistId` is populated from `SubsonicSong.artistId` (Navidrome returns this field) across all 18 track-construction sites.
|
||||
- **Sidebar**: Fixed width via CSS `clamp(200px, 15vw, 220px)` — no drag-to-resize. Collapsed state (72px) persisted in `localStorage`. Update notification uses Tauri Shell plugin `open()` to launch the system browser — `<a target="_blank">` does not work inside a Tauri WebView.
|
||||
- **Tracklist columns**: Order is `# | Title | [Artist (VA only)] | Favorite | Rating | Duration | Format`. Format column uses `120px` (NOT `auto` or `1fr`) — `auto` caused misalignment because header and track-row are independent grid containers: "FORMAT" header text is narrower than "MP3 · 320 kbps", so the `fr` title column calculated differently in header vs rows, shifting all subsequent columns. `1fr` fixed alignment but made the format column too wide. Fixed `120px` fits all codec strings (MP3/FLAC/OGG · kbps) and aligns perfectly. Total row uses explicit `grid-column` numbers (not negative indices).
|
||||
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
|
||||
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
|
||||
- **Statistics page**: Library stat cards (Artists / Albums / Songs / Genres), Recently Played, Most Played, Highest Rated, Genre Chart. Data loaded in parallel via `Promise.allSettled`. No decade distribution (API caps byYear at 200 — all bars show "200+" which is useless).
|
||||
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
|
||||
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`). Year display removed. Header shows song count + total duration below the queue title.
|
||||
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
|
||||
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
|
||||
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
|
||||
- **Random Mix — Genre Filter**: `excludeAudiobooks` + `customGenreBlacklist` in `authStore` (persisted). Hardcoded `AUDIOBOOK_GENRES` list in `RandomMix.tsx` and `AUDIOBOOK_GENRES_DISPLAY` in `Settings.tsx` must be kept in sync. Filter checks `song.genre`, `song.title`, and `song.album`. Clickable genre chips in the tracklist let users add genres to the blacklist on the fly.
|
||||
- **Random Mix — Super Genre Mix**: 9 super-genres defined in `SUPER_GENRES` constant. Server genres fetched via `getGenres()` on mount; `availableSuperGenres` filters to those with ≥1 keyword match. `loadGenreMix` uses progressive rendering — `setGenreMixSongs` updated after each genre request resolves. Genre list capped at 50 (randomly sampled) so total fetch stays near 50 songs — no over-fetching. `genreMixComplete` state gates the "Play All" button: button stays `btn-surface` with live `n / 50` counter while loading, switches to `btn-primary` only when all songs are ready.
|
||||
- **RandomAlbums**: No auto-refresh timer — loads once on mount, manual refresh button only. `loadingRef` guards against concurrent fetches.
|
||||
- **Queue shuffle**: `shuffleQueue()` in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest.
|
||||
- **Tooltip z-index**: `.main-content` has `z-index: 1` so tooltips in the content area render above the queue panel (which has no z-index but appears later in DOM order). Multi-line tooltips: add `data-tooltip-wrap` attribute + use `\n` in the string; CSS rule `[data-tooltip-wrap]::after { white-space: pre-line; max-width: 220px }`.
|
||||
- **Version**: 1.4.5
|
||||
@@ -7,12 +7,13 @@
|
||||
<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>
|
||||
</p>
|
||||
</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) aesthetic.
|
||||
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.
|
||||
|
||||
@@ -33,7 +34,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🔗 **In-App Browser**: Last.fm and Wikipedia links on artist pages open in a dedicated native window — no need to leave the app.
|
||||
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||
|
||||
@@ -47,7 +47,11 @@ Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) p
|
||||
|
||||
- **Windows**: `.exe` or `.msi`
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
- **Linux**: `.AppImage` or `.deb`
|
||||
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
|
||||
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
|
||||
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
|
||||
|
||||
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
src/
|
||||
pkg/
|
||||
*.tar.zst
|
||||
*.tar.gz
|
||||
@@ -0,0 +1,65 @@
|
||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||
pkgname=psysonic
|
||||
pkgver=1.4.5
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Psychotoxical/psysonic"
|
||||
license=('GPL-3.0-only')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
'libayatana-appindicator'
|
||||
'openssl'
|
||||
'alsa-lib'
|
||||
)
|
||||
makedepends=(
|
||||
'npm'
|
||||
'rust'
|
||||
'cargo'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
npm install
|
||||
npm run tauri:build -- --no-bundle
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
|
||||
#!/bin/sh
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec /usr/lib/psysonic/psysonic "\$@"
|
||||
EOF
|
||||
|
||||
# Desktop entry
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/psysonic.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=Psysonic
|
||||
Comment=Desktop music player for Subsonic API-compatible servers
|
||||
Exec=psysonic
|
||||
Icon=psysonic
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
EOF
|
||||
|
||||
# Icons
|
||||
install -Dm644 "src-tauri/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/psysonic.png"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 82 KiB |
@@ -3118,7 +3118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.3.0"
|
||||
version = "1.4.5"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.4.0"
|
||||
version = "1.4.5"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
"shell:allow-open",
|
||||
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","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"],"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://**"}]},"notification:default","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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","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"],"platforms":["linux","macOS","windows"]}}
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 998 B After Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 985 KiB After Width: | Height: | Size: 455 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 24 KiB |
@@ -19,6 +19,7 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
|
||||
pub fn run() {
|
||||
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||
|
||||
@@ -107,9 +108,11 @@ pub fn run() {
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
// Emit event so JS can decide: hide to tray or allow close.
|
||||
// JS handles prevent_close via onCloseRequested() in App.tsx.
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
// Only intercept close for the main window (hide to tray).
|
||||
// Browser popup windows (browser_*) close normally.
|
||||
if window.label() == "main" {
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.5",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -73,7 +73,7 @@ function AppShell() {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
|
||||
});
|
||||
const [queueWidth, setQueueWidth] = useState(300);
|
||||
const [queueWidth, setQueueWidth] = useState(340);
|
||||
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -163,7 +163,7 @@ export async function getAlbumList(
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size };
|
||||
const params: Record<string, string | number> = { size, _t: Date.now() };
|
||||
if (genre) params.genre = genre;
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||
return data.randomSongs?.song ?? [];
|
||||
|
||||
@@ -10,7 +10,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
getCachedUrl(fetchUrl, cacheKey).then(setResolved);
|
||||
let cancelled = false;
|
||||
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return resolved || fetchUrl;
|
||||
}
|
||||
|
||||
@@ -215,6 +215,9 @@ export default function ContextMenu() {
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
||||
<Star size={14} /> {t('contextMenu.favorite')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,7 @@ export default function QueuePanel() {
|
||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
@@ -254,7 +255,23 @@ export default function QueuePanel() {
|
||||
}}
|
||||
>
|
||||
<div className="queue-header">
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
{queue.length > 0 && (() => {
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const h = Math.floor(totalSecs / 3600);
|
||||
const m = Math.floor((totalSecs % 3600) / 60);
|
||||
const s = totalSecs % 60;
|
||||
const dur = h > 0
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
return (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
@@ -299,7 +316,9 @@ export default function QueuePanel() {
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.album}</div>
|
||||
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix ? (
|
||||
@@ -343,7 +362,7 @@ export default function QueuePanel() {
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''}`}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -120,6 +120,7 @@ const enTranslation = {
|
||||
favorite: 'Favorite',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Albums',
|
||||
openedInBrowser: 'Opened in browser',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
@@ -373,7 +374,9 @@ const enTranslation = {
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
emptyQueue: 'The queue is empty.'
|
||||
emptyQueue: 'The queue is empty.',
|
||||
trackSingular: 'track',
|
||||
trackPlural: 'tracks',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistics',
|
||||
@@ -537,6 +540,7 @@ const deTranslation = {
|
||||
favorite: 'Als Favorit',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Alben',
|
||||
openedInBrowser: 'Im Browser geöffnet',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
@@ -790,7 +794,9 @@ const deTranslation = {
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
emptyQueue: 'Die Warteschlange ist leer.'
|
||||
emptyQueue: 'Die Warteschlange ist leer.',
|
||||
trackSingular: 'Titel',
|
||||
trackPlural: 'Titel',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiken',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function ArtistDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
@@ -77,15 +78,10 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const openLink = (url: string, title: string) => {
|
||||
const label = `browser_${Date.now()}`;
|
||||
new WebviewWindow(label, {
|
||||
url,
|
||||
title,
|
||||
width: 1100,
|
||||
height: 780,
|
||||
center: true,
|
||||
});
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
setOpenedLink(key);
|
||||
setTimeout(() => setOpenedLink(null), 2500);
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
@@ -192,14 +188,14 @@ export default function ArtistDetail() {
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, `${artist.name} — Last.fm`)}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, 'lastfm')}>
|
||||
<LastfmIcon size={14} />
|
||||
Last.fm
|
||||
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, `${artist.name} — Wikipedia`)}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
||||
<ExternalLink size={14} />
|
||||
Wikipedia
|
||||
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function NewReleases() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -49,7 +51,7 @@ export default function NewReleases() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>Neueste</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
|
||||
@@ -4,23 +4,14 @@ import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const INTERVAL_MS = 30000;
|
||||
const ALBUM_COUNT = 30;
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const clearTimers = () => {
|
||||
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
||||
if (progressRef.current) { clearInterval(progressRef.current); progressRef.current = null; }
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
@@ -36,27 +27,7 @@ export default function RandomAlbums() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCycle = useCallback(() => {
|
||||
clearTimers();
|
||||
setProgress(0);
|
||||
const startTime = Date.now();
|
||||
progressRef.current = setInterval(() => {
|
||||
setProgress(Math.min((Date.now() - startTime) / INTERVAL_MS * 100, 100));
|
||||
}, 100);
|
||||
timerRef.current = setInterval(() => {
|
||||
load().then(() => startCycle());
|
||||
}, INTERVAL_MS);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
load().then(() => startCycle());
|
||||
return clearTimers;
|
||||
}, [load, startCycle]);
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
clearTimers();
|
||||
load().then(() => startCycle());
|
||||
};
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
@@ -64,7 +35,7 @@ export default function RandomAlbums() {
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleManualRefresh}
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
@@ -73,11 +44,6 @@ export default function RandomAlbums() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Countdown progress bar */}
|
||||
<div className="random-albums-progress">
|
||||
<div className="random-albums-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
|
||||
@@ -58,9 +58,11 @@ export default function RandomMix() {
|
||||
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
|
||||
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
|
||||
const [genreMixLoading, setGenreMixLoading] = useState(false);
|
||||
const [genreMixComplete, setGenreMixComplete] = useState(false);
|
||||
|
||||
const fetchSongs = () => {
|
||||
setLoading(true);
|
||||
setSongs([]);
|
||||
getRandomSongs(50)
|
||||
.then(fetched => {
|
||||
setSongs(fetched);
|
||||
@@ -96,7 +98,11 @@ export default function RandomMix() {
|
||||
});
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (filteredSongs.length > 0) playTrack(filteredSongs[0], filteredSongs);
|
||||
if (selectedSuperGenre && genreMixSongs.length > 0) {
|
||||
playTrack(genreMixSongs[0], genreMixSongs);
|
||||
} else if (filteredSongs.length > 0) {
|
||||
playTrack(filteredSongs[0], filteredSongs);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
@@ -126,10 +132,13 @@ export default function RandomMix() {
|
||||
const loadGenreMix = async (superGenreId: string) => {
|
||||
const sg = SUPER_GENRES.find(s => s.id === superGenreId);
|
||||
if (!sg) return;
|
||||
const matched = serverGenres
|
||||
const allMatched = serverGenres
|
||||
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
|
||||
.map(sg2 => sg2.value);
|
||||
.map(sg2 => sg2.value)
|
||||
.sort(() => Math.random() - 0.5);
|
||||
const matched = allMatched.slice(0, 50);
|
||||
setGenreMixLoading(true);
|
||||
setGenreMixComplete(false);
|
||||
setGenreMixSongs([]);
|
||||
|
||||
const perGenre = Math.max(1, Math.ceil(50 / matched.length));
|
||||
@@ -156,6 +165,7 @@ export default function RandomMix() {
|
||||
return s.slice(0, 50);
|
||||
});
|
||||
setGenreMixLoading(false);
|
||||
setGenreMixComplete(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -168,9 +178,23 @@ export default function RandomMix() {
|
||||
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
|
||||
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || filteredSongs.length === 0}>
|
||||
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
|
||||
</button>
|
||||
{(() => {
|
||||
const isGenreLoading = selectedSuperGenre && !genreMixComplete;
|
||||
const isDisabled = loading || (selectedSuperGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0);
|
||||
return (
|
||||
<button
|
||||
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
|
||||
onClick={handlePlayAll}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isGenreLoading ? (
|
||||
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongs.length, 50)} / 50</>
|
||||
) : (
|
||||
<><Play size={18} fill="currentColor" /> {t('randomMix.playAll')}</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,11 +326,6 @@ export default function RandomMix() {
|
||||
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix
|
||||
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }} onClick={() => genreMixSongs.length > 0 && playTrack(genreMixSongs[0], genreMixSongs)} disabled={genreMixLoading || genreMixSongs.length === 0}>
|
||||
<Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{genreMixLoading && genreMixSongs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||
|
||||
@@ -313,19 +313,7 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.random-albums-progress {
|
||||
height: 2px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.random-albums-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||
}
|
||||
|
||||
@@ -478,15 +478,15 @@
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.queue-current-cover {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
@@ -570,7 +570,7 @@
|
||||
/* Prevent child elements from stealing dragenter/dragleave events */
|
||||
.queue-item > * { pointer-events: none; }
|
||||
|
||||
.queue-item:hover {
|
||||
.queue-item:hover, .queue-item.context-active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
const DB_NAME = 'psysonic-img-cache';
|
||||
const STORE_NAME = 'images';
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const MAX_MEMORY_CACHE = 150; // max object URLs kept in RAM
|
||||
const MAX_CONCURRENT_FETCHES = 5;
|
||||
|
||||
// In-memory map: cacheKey → object URL (avoids creating multiple object URLs per session)
|
||||
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
|
||||
const objectUrlCache = new Map<string, string>();
|
||||
|
||||
// Concurrency limiter for network fetches
|
||||
let activeFetches = 0;
|
||||
const fetchQueue: Array<() => void> = [];
|
||||
|
||||
function acquireFetchSlot(): Promise<void> {
|
||||
if (activeFetches < MAX_CONCURRENT_FETCHES) {
|
||||
activeFetches++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => fetchQueue.push(resolve));
|
||||
}
|
||||
|
||||
function releaseFetchSlot(): void {
|
||||
activeFetches--;
|
||||
const next = fetchQueue.shift();
|
||||
if (next) { activeFetches++; next(); }
|
||||
}
|
||||
|
||||
function evictIfNeeded(): void {
|
||||
while (objectUrlCache.size > MAX_MEMORY_CACHE) {
|
||||
const oldestKey = objectUrlCache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
URL.revokeObjectURL(objectUrlCache.get(oldestKey)!);
|
||||
objectUrlCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
@@ -75,10 +104,12 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictIfNeeded();
|
||||
return objUrl;
|
||||
}
|
||||
|
||||
// 3. Network fetch → store in IDB → return object URL
|
||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL
|
||||
await acquireFetchSlot();
|
||||
try {
|
||||
const resp = await fetch(fetchUrl);
|
||||
if (!resp.ok) return fetchUrl;
|
||||
@@ -86,8 +117,11 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
putBlob(cacheKey, newBlob); // fire-and-forget
|
||||
const objUrl = URL.createObjectURL(newBlob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictIfNeeded();
|
||||
return objUrl;
|
||||
} catch {
|
||||
return fetchUrl; // fallback: direct URL
|
||||
return fetchUrl;
|
||||
} finally {
|
||||
releaseFetchSlot();
|
||||
}
|
||||
}
|
||||
|
||||