Compare commits
5 Commits
app-v1.4.4
..
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e88e8a5cd | |||
| 7de4b97df0 | |||
| 59115a09d2 | |||
| 18199a1f8a | |||
| d4e44199e9 |
@@ -5,6 +5,105 @@ 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.6.0] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
#### Replay Gain
|
||||
- **Replay Gain support** in the Rust audio engine. Gain and peak values from the Subsonic API are applied per-track at playback time, keeping loudness consistent across your library.
|
||||
- Two modes selectable in Settings → Playback: **Track** (default) and **Album** gain.
|
||||
- Peak limiting applied to prevent clipping: effective gain is capped at `1 / peak`.
|
||||
- Volume slider preserves the gain ratio — `audio_set_volume` multiplies `base_volume × replay_gain_linear`.
|
||||
|
||||
#### Crossfade
|
||||
- **Crossfade between tracks** (0.5 – 12 s, configurable in Settings → Playback).
|
||||
- Old sink is volume-ramped to zero in 30 steps while the new track starts playing; old sink stored in `fading_out_sink` so a subsequent skip cancels the fade-out immediately.
|
||||
- `audio_set_crossfade` Tauri command; synced to Rust on startup and on toggle.
|
||||
|
||||
#### Gapless Preloading *(Experimental — Alpha)*
|
||||
- **Gapless playback**: when ≤ 30 s remain in the current track, the next track's audio is preloaded via `audio_preload` in the background.
|
||||
- `audio_play` checks the preload cache first — if there is a URL match the download is skipped entirely, eliminating the gap between tracks.
|
||||
- The old Sink is kept alive during the new track's download and decode phase; the Sink swap happens atomically after decoding is complete, fixing a subtle **start-of-track audio cut** that occurred regardless of gapless state.
|
||||
- ⚠️ **This feature is experimental and still in active development.** It may not work correctly in all scenarios. Enable it in Settings → Playback at your own discretion.
|
||||
|
||||
#### Settings — Tab Navigation
|
||||
- Settings reorganised into **5 horizontal tabs**: Playback, Library, Appearance, Server, About.
|
||||
- Each tab groups related settings with a matching icon.
|
||||
|
||||
#### Artist Pages — "Also Featured On"
|
||||
- Artist detail pages now show an **"Also Featured On"** section listing albums where the artist appears as a guest or featured performer (but is not the primary album artist).
|
||||
- Implemented via `search3` filtered by `song.artistId`, excluding the artist's own albums.
|
||||
|
||||
#### Download Folder Modal
|
||||
- When no download folder is configured and the user initiates a download (album or track), a **folder picker modal** now appears asking where to save.
|
||||
- Includes a "Remember this folder" checkbox that writes the choice to Settings.
|
||||
- Clear button added in Settings → Server to reset the saved download folder.
|
||||
|
||||
#### Changelog in Settings
|
||||
- The full **Changelog** is now readable inside the app under Settings → About.
|
||||
- Rendered as collapsible version entries; the current version is expanded by default.
|
||||
- Inline Markdown (`**bold**`, `*italic*`, `` `code` ``) is rendered natively.
|
||||
|
||||
#### EQ as Player Bar Popup
|
||||
- The Equalizer is now accessible directly from the **player bar** via a small EQ button, opening as a centred popup overlay — no need to navigate to Settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bundle identifier warning**: changed `identifier` from `dev.psysonic.app` to `dev.psysonic.player` to avoid the macOS `.app` extension conflict warned by Tauri.
|
||||
- **Version mismatch in releases**: `tauri.conf.json` version was out of sync with `package.json` and `Cargo.toml`, causing GitHub Actions to build release artefacts with the wrong version number. All four version sources (`package.json`, `Cargo.toml`, `tauri.conf.json`, `packages/aur/PKGBUILD`) are now kept in sync.
|
||||
|
||||
### Known Issues
|
||||
|
||||
- **FLAC seeking**: jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
#### 10-Band Graphic Equalizer
|
||||
- Full **10-band graphic EQ** implemented entirely in the Rust audio engine using biquad peak filters (31 Hz – 16 kHz). Gains adjustable ±12 dB per band.
|
||||
- EQ is processed in the audio pipeline via `EqSource<S>` — a custom `rodio::Source` wrapper that applies cascaded biquad filters in real-time.
|
||||
- Filter coefficients update smoothly on every 1024-sample block without audio interruption.
|
||||
- **Seek support**: `EqSource::try_seek()` implemented — filter state is reset on seek to prevent clicks/artefacts. This also **fixes waveform seek**, which had silently broken when the EQ was introduced (rodio returned `SeekError::NotSupported` without the impl).
|
||||
- **10 built-in presets**: Flat, Bass Boost, Treble Boost, Rock, Pop, Jazz, Classical, Electronic, Vocal, Acoustic.
|
||||
- Custom presets: save, name, and delete your own presets.
|
||||
- EQ state persisted via `psysonic-eq` localStorage key (gains, enabled, active preset, custom presets).
|
||||
- New `audio_set_eq` Tauri command; settings synced to Rust on startup via `eqStore.syncToRust()`.
|
||||
|
||||
#### Connection Indicator
|
||||
- **LED indicator** in the header bar (green = connected, red = disconnected, pulsing = checking). Sits between the search bar and the Now Playing dropdown.
|
||||
- Shows server name and LAN/WAN status next to the LED.
|
||||
- **Offline overlay**: when the server is unreachable, a full-content-area overlay appears with a retry button.
|
||||
- `useConnectionStatus` hook pings the active server periodically and exposes `status`, `isRetrying`, `retry`, `isLan`, and `serverName`.
|
||||
|
||||
#### Now Playing Page
|
||||
- New `/now-playing` route and `NowPlayingPage` component — accessible from the sidebar.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Waveform Seek (Player Bar)
|
||||
- **Drag out of canvas no longer breaks seeking**: `mousemove` and `mouseup` events are now registered on `window` (not the canvas element), so dragging fast across other elements still updates playback position correctly.
|
||||
- **Stale closure fix**: `trackId` and `seek` function are kept in refs so the window-level handlers always see the current values.
|
||||
|
||||
### Changed
|
||||
|
||||
#### App Icon
|
||||
- New app icon (`public/logo-psysonic.png`) across all platforms — Login page, Sidebar, Settings About section, README header, and all generated Tauri platform icons (Windows ICO, macOS ICNS, Linux PNGs, Android, iOS).
|
||||
|
||||
## [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
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# 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). In the grid view, the initial avatar is a **circle** (`border-radius: 50%`, `border: 2px solid` with the accent colour) centred inside the card. Name and album count below are centre-aligned.
|
||||
|
||||
### 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.
|
||||
- **Artist page — external links**: Last.fm and Wikipedia buttons open in the system browser via `open()` from `@tauri-apps/plugin-shell`. Button label temporarily changes to "Opened in browser" / "Im Browser geöffnet" for 2.5 s as visual confirmation.
|
||||
- **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 (if available). Cover art is 90×90 px, top-aligned. Default panel width 340 px. 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.5.0
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.png" alt="Psysonic Logo" width="200"/>
|
||||
<img src="public/logo-psysonic.png" alt="Psysonic Logo" width="200"/>
|
||||
<h1>Psysonic</h1>
|
||||
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
|
||||
|
||||
@@ -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,13 +34,13 @@ 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).
|
||||
|
||||
## ● Known Limitations
|
||||
|
||||
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
|
||||
- **FLAC seeking**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated.
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
@@ -47,7 +48,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,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.5",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
@@ -15,6 +15,7 @@
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
@@ -1522,6 +1523,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-window-state": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz",
|
||||
"integrity": "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.4.4",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||
pkgname=psysonic
|
||||
pkgver=1.4.3
|
||||
pkgver=1.6.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -18,11 +18,11 @@ makedepends=(
|
||||
'rust'
|
||||
'cargo'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
cd "psysonic-$pkgver"
|
||||
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
@@ -32,7 +32,7 @@ build() {
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
cd "psysonic-$pkgver"
|
||||
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -271,6 +271,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "biquad"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "820524f5e3e3add696ddf69f79575772e152c0e78e9f0370b56990a7e808ec3e"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -2164,6 +2173,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fc7aa29613bd6a620df431842069224d8bc9011086b1db4c0e0cd47fa03ec9a"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
@@ -3118,8 +3133,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
"serde",
|
||||
@@ -3132,6 +3148,7 @@ dependencies = [
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -4580,6 +4597,21 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.10.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.4.4"
|
||||
version = "1.6.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -33,3 +33,5 @@ serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
biquad = "0.4"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:scope-download-recursive",
|
||||
"window-state:allow-save-window-state",
|
||||
"window-state:allow-restore-state",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"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","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -6451,6 +6451,48 @@
|
||||
"type": "string",
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
"const": "window-state:default",
|
||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the filename command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-filename",
|
||||
"markdownDescription": "Enables the filename command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restore_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-restore-state",
|
||||
"markdownDescription": "Enables the restore_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_window_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-save-window-state",
|
||||
"markdownDescription": "Enables the save_window_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the filename command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-filename",
|
||||
"markdownDescription": "Denies the filename command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restore_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-restore-state",
|
||||
"markdownDescription": "Denies the restore_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_window_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-save-window-state",
|
||||
"markdownDescription": "Denies the save_window_state command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6451,6 +6451,48 @@
|
||||
"type": "string",
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
"const": "window-state:default",
|
||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the filename command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-filename",
|
||||
"markdownDescription": "Enables the filename command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restore_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-restore-state",
|
||||
"markdownDescription": "Enables the restore_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_window_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-save-window-state",
|
||||
"markdownDescription": "Enables the save_window_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the filename command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-filename",
|
||||
"markdownDescription": "Denies the filename command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restore_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-restore-state",
|
||||
"markdownDescription": "Denies the restore_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_window_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-save-window-state",
|
||||
"markdownDescription": "Denies the save_window_state command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 838 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 829 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 47 KiB |
@@ -1,16 +1,148 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
||||
use rodio::{Decoder, Sink, Source};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
||||
|
||||
const EQ_BANDS_HZ: [f32; 10] = [31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||
const EQ_Q: f32 = 1.41;
|
||||
const EQ_CHECK_INTERVAL: usize = 1024;
|
||||
|
||||
struct EqSource<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
gains: Arc<[AtomicU32; 10]>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
filters: [[DirectForm2Transposed<f32>; 2]; 10],
|
||||
current_gains: [f32; 10],
|
||||
sample_counter: usize,
|
||||
channel_idx: usize,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> EqSource<S> {
|
||||
fn new(inner: S, gains: Arc<[AtomicU32; 10]>, enabled: Arc<AtomicBool>) -> Self {
|
||||
let sample_rate = inner.sample_rate();
|
||||
let channels = inner.channels();
|
||||
let filters = std::array::from_fn(|band| {
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate as f32 / 2.0) - 100.0);
|
||||
std::array::from_fn(|_| {
|
||||
let coeffs = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(0.0),
|
||||
(sample_rate as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
).unwrap_or_else(|_| Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(0.0),
|
||||
(sample_rate as f32).hz(),
|
||||
1000.0f32.hz(),
|
||||
EQ_Q,
|
||||
).unwrap());
|
||||
DirectForm2Transposed::<f32>::new(coeffs)
|
||||
})
|
||||
});
|
||||
Self {
|
||||
inner, sample_rate, channels, gains, enabled,
|
||||
filters,
|
||||
current_gains: [0.0; 10],
|
||||
sample_counter: 0,
|
||||
channel_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_if_needed(&mut self) {
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
if (gain_db - self.current_gains[band]).abs() > 0.01 {
|
||||
self.current_gains[band] = gain_db;
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate as f32 / 2.0) - 100.0);
|
||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(gain_db),
|
||||
(self.sample_rate as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
) {
|
||||
for ch in 0..2 {
|
||||
self.filters[band][ch].update_coefficients(coeffs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for EqSource<S> {
|
||||
type Item = f32;
|
||||
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next()?;
|
||||
|
||||
if self.sample_counter % EQ_CHECK_INTERVAL == 0 {
|
||||
self.refresh_if_needed();
|
||||
}
|
||||
self.sample_counter = self.sample_counter.wrapping_add(1);
|
||||
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
|
||||
return Some(sample);
|
||||
}
|
||||
|
||||
let ch = self.channel_idx.min(1);
|
||||
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
|
||||
|
||||
let mut s = sample;
|
||||
for band in 0..10 {
|
||||
s = self.filters[band][ch].run(s);
|
||||
}
|
||||
Some(s.clamp(-1.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for EqSource<S> {
|
||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
||||
fn channels(&self) -> u16 { self.channels }
|
||||
fn sample_rate(&self) -> u32 { self.sample_rate }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Reset biquad filter state to avoid glitches/clicks after seek.
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
self.current_gains[band] = gain_db;
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate as f32 / 2.0) - 100.0);
|
||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(gain_db),
|
||||
(self.sample_rate as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
) {
|
||||
for ch in 0..2 {
|
||||
self.filters[band][ch] = DirectForm2Transposed::<f32>::new(coeffs);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.channel_idx = 0;
|
||||
self.sample_counter = 0;
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Debug logger ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Engine state (registered as Tauri managed state) ────────────────────────
|
||||
|
||||
pub(crate) struct PreloadedTrack {
|
||||
url: String,
|
||||
data: Vec<u8>,
|
||||
duration_hint: f64,
|
||||
}
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub stream_handle: Arc<rodio::OutputStreamHandle>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
@@ -19,6 +151,12 @@ pub struct AudioEngine {
|
||||
/// bails out if this counter has moved on, preventing stale events.
|
||||
pub generation: Arc<AtomicU64>,
|
||||
pub http_client: reqwest::Client,
|
||||
pub eq_gains: Arc<[AtomicU32; 10]>,
|
||||
pub eq_enabled: Arc<AtomicBool>,
|
||||
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
pub crossfade_enabled: Arc<AtomicBool>,
|
||||
pub crossfade_secs: Arc<AtomicU32>, // f32 stored as bits
|
||||
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
@@ -31,6 +169,8 @@ pub struct AudioCurrent {
|
||||
pub play_started: Option<Instant>,
|
||||
/// Set when paused; holds the position at pause time.
|
||||
pub paused_at: Option<f64>,
|
||||
pub replay_gain_linear: f32,
|
||||
pub base_volume: f32,
|
||||
}
|
||||
|
||||
impl AudioCurrent {
|
||||
@@ -79,12 +219,20 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 0.8,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
@@ -108,43 +256,61 @@ pub async fn audio_play(
|
||||
url: String,
|
||||
volume: f32,
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Claim this generation — any in-flight progress task with the old gen will exit.
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Stop existing playback immediately.
|
||||
// Stop any previous fading sink unconditionally.
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() {
|
||||
sink.stop();
|
||||
let mut fo = state.fading_out_sink.lock().unwrap();
|
||||
if let Some(old) = fo.take() {
|
||||
old.stop();
|
||||
}
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
cur.duration_secs = duration_hint;
|
||||
}
|
||||
|
||||
// ── Download ──────────────────────────────────────────────────────────────
|
||||
let response = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// NOTE: We intentionally do NOT stop the current Sink here.
|
||||
// The old Sink keeps playing while we download + decode the new track.
|
||||
// We swap Sinks atomically only after the new data is ready, so the
|
||||
// old track plays to completion (or as close to it as possible).
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
// ── Check preload cache or download ──────────────────────────────────────
|
||||
let data: Vec<u8> = {
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if let Some(p) = preloaded.as_ref().filter(|p| p.url == url) {
|
||||
let d = p.data.clone();
|
||||
let _ = p.duration_hint; // used for type check
|
||||
*preloaded = None;
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(cached_data) = cached {
|
||||
cached_data
|
||||
} else {
|
||||
let response = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||
}
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
};
|
||||
|
||||
// Bail if superseded while downloading.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
@@ -152,7 +318,6 @@ pub async fn audio_play(
|
||||
}
|
||||
|
||||
// ── Decode ────────────────────────────────────────────────────────────────
|
||||
let data: Vec<u8> = bytes.into();
|
||||
|
||||
// Trust the Subsonic API duration_hint as the primary source.
|
||||
// Decoder::total_duration() is unreliable for VBR MP3 (symphonia may
|
||||
@@ -181,18 +346,71 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Create sink and start playback ────────────────────────────────────────
|
||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||
sink.set_volume(volume.clamp(0.0, 1.0));
|
||||
sink.append(decoder);
|
||||
// ── Compute replay gain ───────────────────────────────────────────────────
|
||||
let gain_linear: f32 = replay_gain_db
|
||||
.map(|db| 10f32.powf(db / 20.0))
|
||||
.unwrap_or(1.0);
|
||||
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
|
||||
// Limit gain so peak sample doesn't exceed 1.0 (prevent clipping).
|
||||
let gain_linear = gain_linear.min(1.0 / peak);
|
||||
let effective_volume = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
|
||||
|
||||
{
|
||||
// ── Create new sink ───────────────────────────────────────────────────────
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
|
||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||
sink.set_volume(effective_volume);
|
||||
let eq_source = EqSource::new(
|
||||
decoder.convert_samples::<f32>(),
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
);
|
||||
sink.append(eq_source);
|
||||
|
||||
// Atomically swap sinks: take old one, install new one.
|
||||
// Capture old volume so the crossfade fade-out starts at the right level.
|
||||
let (old_sink, old_vol) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0);
|
||||
let old = cur.sink.take();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
(old, old_vol)
|
||||
};
|
||||
|
||||
// Handle old sink: crossfade fade-out or immediate stop.
|
||||
// The new track is already playing; the old sink runs in parallel during a crossfade.
|
||||
if crossfade_enabled {
|
||||
if let Some(old) = old_sink {
|
||||
// Park the old sink in fading_out_sink so a subsequent audio_play can cancel it.
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
tokio::spawn(async move {
|
||||
let steps: u32 = 30;
|
||||
let step_ms = ((crossfade_secs_val * 1000.0) / steps as f32) as u64;
|
||||
for i in (0..=steps).rev() {
|
||||
{
|
||||
let fo = fo_arc.lock().unwrap();
|
||||
match fo.as_ref() {
|
||||
Some(s) => s.set_volume(old_vol * (i as f32 / steps as f32)),
|
||||
None => return, // cancelled by a subsequent audio_play
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(step_ms)).await;
|
||||
}
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
@@ -205,13 +423,14 @@ pub async fn audio_play(
|
||||
// Instead we use the wall-clock position (seek_offset + elapsed).
|
||||
// `AudioCurrent::position()` is clamped to `duration_secs`, so once it
|
||||
// reaches the end it stays there. We fire `audio:ended` after two
|
||||
// consecutive ticks where position >= duration - 1.0 s, which:
|
||||
// • avoids false positives from seeking very close to the end
|
||||
// • fires roughly 0.5–1 s before the last sample, giving the frontend
|
||||
// enough time to queue the next download.
|
||||
// consecutive ticks where position >= duration - threshold, where threshold
|
||||
// is extended to crossfade_secs when crossfade is enabled so the frontend
|
||||
// gets time to start the next track during the fade.
|
||||
let gen_counter = state.generation.clone();
|
||||
let current_arc = state.current.clone();
|
||||
let app_clone = app.clone();
|
||||
let crossfade_enabled_arc = state.crossfade_enabled.clone();
|
||||
let crossfade_secs_arc = state.crossfade_secs.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
@@ -240,8 +459,11 @@ pub async fn audio_play(
|
||||
continue;
|
||||
}
|
||||
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
|
||||
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
|
||||
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
|
||||
|
||||
if dur > 1.0 && pos >= dur - 1.0 {
|
||||
if dur > end_threshold && pos >= dur - end_threshold {
|
||||
near_end_ticks += 1;
|
||||
if near_end_ticks >= 2 {
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
@@ -315,8 +537,50 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.set_volume(volume.clamp(0.0, 1.0));
|
||||
sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.eq_enabled.store(enabled, Ordering::Relaxed);
|
||||
for (i, &gain) in gains.iter().enumerate() {
|
||||
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Don't re-download if already preloaded for this URL.
|
||||
{
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let response = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Ok(()); // silently fail preload
|
||||
}
|
||||
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data, duration_hint });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
|
||||
pub fn run() {
|
||||
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
@@ -107,9 +109,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![
|
||||
@@ -121,6 +125,9 @@ pub fn run() {
|
||||
audio::audio_stop,
|
||||
audio::audio_seek,
|
||||
audio::audio_set_volume,
|
||||
audio::audio_set_eq,
|
||||
audio::audio_preload,
|
||||
audio::audio_set_crossfade,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.4.4",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"version": "1.6.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
|
||||
@@ -26,11 +26,17 @@ import Playlists from './pages/Playlists';
|
||||
import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useEqStore } from './store/eqStore';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
@@ -47,11 +53,16 @@ function AppShell() {
|
||||
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
}, [initializeFromServerQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
useEqStore.getState().syncToRust();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = async () => {
|
||||
try {
|
||||
@@ -73,7 +84,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(() => {
|
||||
@@ -127,6 +138,7 @@ function AppShell() {
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<NowPlayingDropdown />
|
||||
<button
|
||||
className="collapse-btn"
|
||||
@@ -136,7 +148,14 @@ function AppShell() {
|
||||
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
|
||||
</button>
|
||||
</header>
|
||||
<div className="content-body" style={{ padding: 0 }}>
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineOverlay
|
||||
serverName={serverName}
|
||||
onRetry={connRetry}
|
||||
isChecking={connRetrying}
|
||||
/>
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
@@ -151,6 +170,7 @@ function AppShell() {
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/now-playing" element={<NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
</Routes>
|
||||
@@ -170,6 +190,7 @@ function AppShell() {
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
<DownloadFolderModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,13 @@ export interface SubsonicSong {
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
path?: string;
|
||||
albumArtist?: string;
|
||||
replayGain?: {
|
||||
trackGain?: number;
|
||||
albumGain?: number;
|
||||
trackPeak?: number;
|
||||
albumPeak?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
@@ -169,6 +176,15 @@ export async function getRandomSongs(size = 50, genre?: string, timeout = 15000)
|
||||
return data.randomSongs?.song ?? [];
|
||||
}
|
||||
|
||||
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||
try {
|
||||
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
||||
return data.song ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
isLan: boolean;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const title =
|
||||
status === 'connected'
|
||||
? t('connection.connected')
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnected')
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div className="connection-indicator" title={title}>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type">{label}</span>
|
||||
<span className="connection-server">{serverName}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
@@ -21,6 +22,7 @@ export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -74,25 +76,16 @@ export default function ContextMenu() {
|
||||
|
||||
const downloadAlbum = async (albumName: string, albumId: string) => {
|
||||
try {
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${sanitizeFilename(albumName)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function DownloadFolderModal() {
|
||||
const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore();
|
||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('common.chooseDownloadFolder') });
|
||||
if (selected && typeof selected === 'string') setFolder(selected);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={cancel} style={{ zIndex: 210 }} />
|
||||
<div className="eq-popup" style={{ zIndex: 211, width: 'min(480px, 92vw)', gap: 0 }}>
|
||||
<div className="eq-popup-header">
|
||||
<span className="eq-popup-title">{t('common.chooseDownloadFolder')}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 0 12px' }}>
|
||||
<div className="download-folder-pick-row">
|
||||
<span className="download-folder-path">
|
||||
{folder || t('common.noFolderSelected')}
|
||||
</span>
|
||||
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={15} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="download-remember-row">
|
||||
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
|
||||
<span>{t('common.rememberDownloadFolder')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="download-modal-actions">
|
||||
<button className="btn btn-ghost" onClick={cancel}>{t('common.cancel')}</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => confirm(setDownloadFolder)}
|
||||
disabled={!folder}
|
||||
>
|
||||
{t('common.download')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
// ─── Frequency response canvas ────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const EQ_Q = 1.41;
|
||||
|
||||
function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number {
|
||||
if (Math.abs(gainDb) < 0.01) return 0;
|
||||
const w0 = (2 * Math.PI * centerHz) / sampleRate;
|
||||
const A = Math.pow(10, gainDb / 40);
|
||||
const alpha = Math.sin(w0) / (2 * EQ_Q);
|
||||
const b0 = 1 + alpha * A;
|
||||
const b1 = -2 * Math.cos(w0);
|
||||
const b2 = 1 - alpha * A;
|
||||
const a0 = 1 + alpha / A;
|
||||
const a1 = -2 * Math.cos(w0);
|
||||
const a2 = 1 - alpha / A;
|
||||
const w = (2 * Math.PI * freq) / sampleRate;
|
||||
const cosW = Math.cos(w), sinW = Math.sin(w);
|
||||
const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w);
|
||||
const numRe = b0 + b1 * cosW + b2 * cos2W;
|
||||
const numIm = - b1 * sinW - b2 * sin2W;
|
||||
const denRe = a0 + a1 * cosW + a2 * cos2W;
|
||||
const denIm = - a1 * sinW - a2 * sin2W;
|
||||
const numMag2 = numRe * numRe + numIm * numIm;
|
||||
const denMag2 = denRe * denRe + denIm * denIm;
|
||||
return 10 * Math.log10(numMag2 / denMag2);
|
||||
}
|
||||
|
||||
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.offsetWidth;
|
||||
const H = canvas.offsetHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const fMin = 20, fMax = 20000;
|
||||
const dbMin = -13, dbMax = 13;
|
||||
const padL = 36, padR = 8, padT = 8, padB = 1;
|
||||
const innerW = W - padL - padR;
|
||||
const innerH = H - padT - padB;
|
||||
|
||||
const freqToX = (f: number) =>
|
||||
padL + (Math.log10(f / fMin) / Math.log10(fMax / fMin)) * innerW;
|
||||
const dbToY = (db: number) =>
|
||||
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Grid: dB lines
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.lineWidth = 1;
|
||||
[-12, -6, 0, 6, 12].forEach(db => {
|
||||
const y = dbToY(db);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, y);
|
||||
ctx.lineTo(W - padR, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
|
||||
});
|
||||
|
||||
// Grid: frequency lines
|
||||
[31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].forEach(f => {
|
||||
const x = freqToX(f);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padT);
|
||||
ctx.lineTo(x, H - padB);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// Zero line (brighter)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, dbToY(0));
|
||||
ctx.lineTo(W - padR, dbToY(0));
|
||||
ctx.stroke();
|
||||
|
||||
// Frequency response curve
|
||||
const points: [number, number][] = [];
|
||||
const steps = innerW * 2;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = fMin * Math.pow(fMax / fMin, i / steps);
|
||||
let totalDb = 0;
|
||||
for (let band = 0; band < 10; band++) {
|
||||
totalDb += biquadPeakResponse(f, EQ_BANDS[band].freq, gains[band], SAMPLE_RATE);
|
||||
}
|
||||
totalDb = Math.max(dbMin, Math.min(dbMax, totalDb));
|
||||
points.push([freqToX(f), dbToY(totalDb)]);
|
||||
}
|
||||
|
||||
// Fill under curve
|
||||
const grad = ctx.createLinearGradient(0, padT, 0, H);
|
||||
grad.addColorStop(0, accentColor.replace(')', ', 0.25)').replace('rgb', 'rgba'));
|
||||
grad.addColorStop(1, accentColor.replace(')', ', 0.0)').replace('rgb', 'rgba'));
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], dbToY(0));
|
||||
points.forEach(([x, y]) => ctx.lineTo(x, y));
|
||||
ctx.lineTo(points[points.length - 1][0], dbToY(0));
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// Curve line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
points.forEach(([x, y]) => ctx.lineTo(x, y));
|
||||
ctx.strokeStyle = accentColor;
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ─── Custom vertical fader (no native range input) ────────────────────────────
|
||||
|
||||
const GAIN_MIN = -12, GAIN_MAX = 12;
|
||||
|
||||
interface FaderProps {
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
function VerticalFader({ value, disabled, onChange }: FaderProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom
|
||||
const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN);
|
||||
|
||||
const updateFromY = useCallback((clientY: number) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
dragging.current = true;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragging.current || disabled) return;
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerUp = () => { dragging.current = false; };
|
||||
|
||||
const thumbPct = gainToPct(value) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="eq-fader-custom"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="eq-track-line" />
|
||||
<div className="eq-thumb" style={{ top: `${thumbPct}%`, opacity: disabled ? 0.3 : 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Equalizer() {
|
||||
const { t } = useTranslation();
|
||||
const gains = useEqStore(s => s.gains);
|
||||
const enabled = useEqStore(s => s.enabled);
|
||||
const activePreset = useEqStore(s => s.activePreset);
|
||||
const customPresets = useEqStore(s => s.customPresets);
|
||||
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
|
||||
|
||||
const [saveName, setSaveName] = useState('');
|
||||
const [showSave, setShowSave] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
|
||||
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
|
||||
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
|
||||
drawCurve(canvas, gains, accent, bg, text);
|
||||
}, [gains, theme]);
|
||||
|
||||
useEffect(() => { redraw(); }, [redraw]);
|
||||
|
||||
useEffect(() => {
|
||||
const ro = new ResizeObserver(redraw);
|
||||
if (canvasRef.current) ro.observe(canvasRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
|
||||
const selectValue = activePreset ?? '__custom__';
|
||||
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
|
||||
|
||||
const handleSave = () => {
|
||||
const name = saveName.trim();
|
||||
if (!name) return;
|
||||
saveCustomPreset(name);
|
||||
setSaveName('');
|
||||
setShowSave(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="eq-wrap">
|
||||
{/* Controls bar */}
|
||||
<div className="eq-controls-bar">
|
||||
<label className="eq-toggle-label">
|
||||
<span>{t('settings.eqEnabled')}</span>
|
||||
<label className="toggle-switch" style={{ marginLeft: 8 }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<div className="eq-preset-row">
|
||||
<select
|
||||
className="input eq-preset-select"
|
||||
value={selectValue}
|
||||
onChange={e => applyPreset(e.target.value)}
|
||||
>
|
||||
{activePreset === null && <option value="__custom__">{t('settings.eqPresetCustom')}</option>}
|
||||
<optgroup label={t('settings.eqPresetBuiltin')}>
|
||||
{BUILTIN_PRESETS.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
||||
</optgroup>
|
||||
{customPresets.length > 0 && (
|
||||
<optgroup label={t('settings.eqPresetCustomGroup')}>
|
||||
{customPresets.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
|
||||
{isCustomSaved && (
|
||||
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} title={t('settings.eqDeletePreset')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} title={t('settings.eqResetBands')}>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} title={t('settings.eqSavePreset')}>
|
||||
<Save size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSave && (
|
||||
<div className="eq-save-row">
|
||||
<input
|
||||
type="text" className="input" placeholder={t('settings.eqPresetName')}
|
||||
value={saveName} onChange={e => setSaveName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||
autoFocus style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={!saveName.trim()}>{t('common.save')}</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setShowSave(false); setSaveName(''); }}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EQ panel */}
|
||||
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
|
||||
{/* Frequency response */}
|
||||
<canvas ref={canvasRef} className="eq-canvas" />
|
||||
|
||||
{/* Fader area */}
|
||||
<div className="eq-faders">
|
||||
{/* dB scale */}
|
||||
<div className="eq-db-scale">
|
||||
{[12, 6, 0, -6, -12].map(db => (
|
||||
<span key={db} className="eq-db-tick">
|
||||
{db > 0 ? `+${db}` : db}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bands */}
|
||||
{EQ_BANDS.map((band, i) => (
|
||||
<div key={band.freq} className="eq-band">
|
||||
<span className="eq-gain-val">
|
||||
{gains[i] > 0 ? '+' : ''}{gains[i].toFixed(1)}
|
||||
</span>
|
||||
<div className="eq-fader-track">
|
||||
<div className="eq-zero-mark" />
|
||||
<VerticalFader
|
||||
value={gains[i]}
|
||||
disabled={!enabled}
|
||||
onChange={v => setBandGain(i, v)}
|
||||
/>
|
||||
</div>
|
||||
<span className="eq-freq-label">{band.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WifiOff, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
serverName: string;
|
||||
onRetry: () => void;
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export default function OfflineOverlay({ serverName, onRetry, isChecking }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="offline-overlay">
|
||||
<div className="offline-overlay-card">
|
||||
<WifiOff size={48} className="offline-icon" />
|
||||
<h2 className="offline-title">{t('connection.offlineTitle')}</h2>
|
||||
<p className="offline-subtitle">
|
||||
{t('connection.offlineSubtitle', { server: serverName })}
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary offline-retry"
|
||||
onClick={onRetry}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw size={16} className={isChecking ? 'spin' : ''} />
|
||||
{isChecking ? t('connection.checking') : t('connection.retry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -20,6 +21,7 @@ function formatTime(seconds: number): string {
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [eqOpen, setEqOpen] = useState(false);
|
||||
const {
|
||||
currentTrack, isPlaying, currentTime, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
@@ -125,6 +127,16 @@ export default function PlayerBar() {
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* EQ Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||
onClick={() => setEqOpen(v => !v)}
|
||||
aria-label="Equalizer"
|
||||
data-tooltip="Equalizer"
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
@@ -149,6 +161,22 @@ export default function PlayerBar() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EQ Popup */}
|
||||
{eqOpen && (
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
|
||||
<div className="eq-popup">
|
||||
<div className="eq-popup-header">
|
||||
<span className="eq-popup-title">Equalizer</span>
|
||||
<button className="eq-popup-close" onClick={() => setEqOpen(false)} aria-label="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<Equalizer />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,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 ? (
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<img src="/logo.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
|
||||
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
|
||||
);
|
||||
|
||||
const navItems = [
|
||||
@@ -68,6 +69,8 @@ export default function Sidebar({
|
||||
toggleCollapse?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,7 +126,21 @@ export default function Sidebar({
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
|
||||
{/* Now Playing — special styled */}
|
||||
<NavLink
|
||||
to="/now-playing"
|
||||
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
|
||||
title={isCollapsed ? t('sidebar.nowPlaying') : undefined}
|
||||
style={{ marginTop: 'auto' }}
|
||||
>
|
||||
<span className="nav-np-icon-wrap">
|
||||
<AudioLines size={isCollapsed ? 22 : 18} />
|
||||
{isPlaying && currentTrack && <span className="nav-np-dot" />}
|
||||
</span>
|
||||
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
|
||||
</NavLink>
|
||||
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
|
||||
<NavLink
|
||||
to="/statistics"
|
||||
|
||||
@@ -156,25 +156,37 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const seekFromX = (clientX: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !trackId) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
seek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
|
||||
};
|
||||
const trackIdRef = useRef(trackId);
|
||||
trackIdRef.current = trackId;
|
||||
const seekRef = useRef(seek);
|
||||
seekRef.current = seek;
|
||||
|
||||
useEffect(() => {
|
||||
const up = () => { isDragging.current = false; };
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => window.removeEventListener('mouseup', up);
|
||||
const seekFromX = (clientX: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !trackIdRef.current) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
|
||||
};
|
||||
const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
|
||||
const onUp = () => { isDragging.current = false; };
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
|
||||
onMouseDown={e => { isDragging.current = true; seekFromX(e.clientX); }}
|
||||
onMouseMove={e => { if (isDragging.current) seekFromX(e.clientX); }}
|
||||
onMouseDown={e => {
|
||||
isDragging.current = true;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
|
||||
export function isLanUrl(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url.startsWith('http') ? url : `http://${url}`).hostname;
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.local') ||
|
||||
/^127\./.test(hostname) ||
|
||||
/^10\./.test(hostname) ||
|
||||
/^192\.168\./.test(hostname) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useConnectionStatus() {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('checking');
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
if (!server) {
|
||||
setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.onLine) {
|
||||
setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
||||
setStatus(ok ? 'connected' : 'disconnected');
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
setIsRetrying(true);
|
||||
await check();
|
||||
setIsRetrying(false);
|
||||
}, [check]);
|
||||
|
||||
useEffect(() => {
|
||||
check();
|
||||
intervalRef.current = setInterval(check, 30_000);
|
||||
|
||||
const handleOnline = () => check();
|
||||
const handleOffline = () => setStatus('disconnected');
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [check]);
|
||||
|
||||
const server = useAuthStore(s => s.getActiveServer());
|
||||
|
||||
return {
|
||||
status,
|
||||
isRetrying,
|
||||
retry,
|
||||
isLan: server ? isLanUrl(server.url) : false,
|
||||
serverName: server?.name ?? '',
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const enTranslation = {
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Random Mix',
|
||||
favorites: 'Favorites',
|
||||
nowPlaying: 'Now Playing',
|
||||
system: 'System',
|
||||
statistics: 'Statistics',
|
||||
settings: 'Settings',
|
||||
@@ -54,6 +55,7 @@ const enTranslation = {
|
||||
loading: 'Loading…',
|
||||
nobody: 'Nobody is currently listening.',
|
||||
minutesAgo: '{{n}}m ago',
|
||||
nothingPlaying: 'Nothing playing yet. Start a track!',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Play Now',
|
||||
@@ -120,6 +122,8 @@ const enTranslation = {
|
||||
favorite: 'Favorite',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Albums',
|
||||
openedInBrowser: 'Opened in browser',
|
||||
featuredOn: 'Also Featured On',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
@@ -213,6 +217,15 @@ const enTranslation = {
|
||||
savedServers: 'Saved Servers',
|
||||
addNew: 'Or add a new server',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
checking: 'Connecting…',
|
||||
extern: 'Extern',
|
||||
offlineTitle: 'No server connection',
|
||||
offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.',
|
||||
retry: 'Retry',
|
||||
},
|
||||
common: {
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
@@ -229,6 +242,10 @@ const enTranslation = {
|
||||
use: 'Use',
|
||||
add: 'Add',
|
||||
active: 'Active',
|
||||
download: 'Download',
|
||||
chooseDownloadFolder: 'Choose download folder',
|
||||
noFolderSelected: 'No folder selected',
|
||||
rememberDownloadFolder: 'Remember this folder',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -256,6 +273,16 @@ const enTranslation = {
|
||||
testingBtn: 'Testing…',
|
||||
connected: 'Connected',
|
||||
failed: 'Failed',
|
||||
eqTitle: 'Equalizer',
|
||||
eqEnabled: 'Enable Equalizer',
|
||||
eqPreset: 'Preset',
|
||||
eqPresetCustom: 'Custom',
|
||||
eqPresetBuiltin: 'Built-in Presets',
|
||||
eqPresetCustomGroup: 'My Presets',
|
||||
eqSavePreset: 'Save as Preset',
|
||||
eqPresetName: 'Preset name…',
|
||||
eqDeletePreset: 'Delete preset',
|
||||
eqResetBands: 'Reset to Flat',
|
||||
lfmTitle: 'Last.fm Scrobbling',
|
||||
lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the',
|
||||
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
|
||||
@@ -272,16 +299,18 @@ const enTranslation = {
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
pickFolderTitle: 'Select Download Folder',
|
||||
clearFolder: 'Clear download folder',
|
||||
logout: 'Logout',
|
||||
aboutTitle: 'About Psysonic',
|
||||
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
|
||||
aboutFeatures: 'Multi-server support · Scrobbling · Fullscreen player · Album downloads · Image caching · Catppuccin themes',
|
||||
aboutLicense: 'License',
|
||||
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
|
||||
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
|
||||
aboutRepo: 'Source Code on GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
changelog: 'Changelog',
|
||||
randomMixTitle: 'Random Mix',
|
||||
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
||||
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).',
|
||||
@@ -289,6 +318,22 @@ const enTranslation = {
|
||||
randomMixBlacklistAdd: 'Add',
|
||||
randomMixBlacklistEmpty: 'No custom keywords added yet.',
|
||||
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAppearance: 'Appearance',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'About',
|
||||
playbackTitle: 'Playback',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
|
||||
replayGainMode: 'Mode',
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
@@ -432,6 +477,7 @@ const deTranslation = {
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Zufallsmix',
|
||||
favorites: 'Favoriten',
|
||||
nowPlaying: 'Now Playing',
|
||||
system: 'System',
|
||||
statistics: 'Statistiken',
|
||||
settings: 'Einstellungen',
|
||||
@@ -473,6 +519,7 @@ const deTranslation = {
|
||||
loading: 'Lädt…',
|
||||
nobody: 'Gerade hört niemand Musik.',
|
||||
minutesAgo: 'vor {{n}}m',
|
||||
nothingPlaying: 'Noch nichts am Laufen. Leg einen Track auf!',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Direkt abspielen',
|
||||
@@ -539,6 +586,8 @@ const deTranslation = {
|
||||
favorite: 'Als Favorit',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Alben',
|
||||
openedInBrowser: 'Im Browser geöffnet',
|
||||
featuredOn: 'Auch enthalten auf',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
@@ -632,6 +681,15 @@ const deTranslation = {
|
||||
savedServers: 'Gespeicherte Server',
|
||||
addNew: 'Oder neuen Server hinzufügen',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Verbunden',
|
||||
disconnected: 'Getrennt',
|
||||
checking: 'Verbinde…',
|
||||
extern: 'Extern',
|
||||
offlineTitle: 'Keine Serververbindung',
|
||||
offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.',
|
||||
retry: 'Erneut versuchen',
|
||||
},
|
||||
common: {
|
||||
albums: 'Alben',
|
||||
album: 'Album',
|
||||
@@ -648,6 +706,10 @@ const deTranslation = {
|
||||
use: 'Verwenden',
|
||||
add: 'Hinzufügen',
|
||||
active: 'Aktiv',
|
||||
download: 'Herunterladen',
|
||||
chooseDownloadFolder: 'Download-Ordner wählen',
|
||||
noFolderSelected: 'Kein Ordner ausgewählt',
|
||||
rememberDownloadFolder: 'Ordner merken',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -675,6 +737,16 @@ const deTranslation = {
|
||||
testingBtn: 'Teste…',
|
||||
connected: 'Verbunden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
eqTitle: 'Equalizer',
|
||||
eqEnabled: 'Equalizer aktivieren',
|
||||
eqPreset: 'Preset',
|
||||
eqPresetCustom: 'Benutzerdefiniert',
|
||||
eqPresetBuiltin: 'Eingebaute Presets',
|
||||
eqPresetCustomGroup: 'Meine Presets',
|
||||
eqSavePreset: 'Als Preset speichern',
|
||||
eqPresetName: 'Preset-Name…',
|
||||
eqDeletePreset: 'Preset löschen',
|
||||
eqResetBands: 'Auf Flat zurücksetzen',
|
||||
lfmTitle: 'Last.fm Scrobbling',
|
||||
lfmDesc1: 'Psysonic unterstützt serverseitiges Scrobbling direkt über Navidrome. Um Last.fm zu verknüpfen, logge dich bitte einmalig über den',
|
||||
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
|
||||
@@ -691,16 +763,18 @@ const deTranslation = {
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
pickFolderTitle: 'Download-Ordner auswählen',
|
||||
clearFolder: 'Download-Ordner löschen',
|
||||
logout: 'Abmelden',
|
||||
aboutTitle: 'Über Psysonic',
|
||||
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
|
||||
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
|
||||
aboutLicense: 'Lizenz',
|
||||
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
|
||||
aboutLicenseText: 'GNU GPL v3 — kostenlos nutzbar, veränderbar und unter gleicher Lizenz weiterzugeben.',
|
||||
aboutRepo: 'Quellcode auf GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||
changelog: 'Changelog',
|
||||
randomMixTitle: 'Zufallsmix',
|
||||
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
||||
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).',
|
||||
@@ -708,6 +782,22 @@ const deTranslation = {
|
||||
randomMixBlacklistAdd: 'Hinzufügen',
|
||||
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
|
||||
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAppearance: 'Darstellung',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'Info',
|
||||
playbackTitle: 'Wiedergabe',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren',
|
||||
replayGainMode: 'Modus',
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
},
|
||||
help: {
|
||||
title: 'Hilfe',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
@@ -24,6 +25,7 @@ export default function AlbumDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
@@ -106,6 +108,11 @@ export default function AlbumDetail() {
|
||||
const handleDownload = async () => {
|
||||
if (!album) return;
|
||||
const { name, id: albumId } = album.album;
|
||||
|
||||
// Ask for folder before starting download if not already set
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
@@ -133,20 +140,9 @@ export default function AlbumDetail() {
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${sanitizeFilename(name)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
setDownloadProgress(null);
|
||||
|
||||
@@ -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 { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -45,11 +45,13 @@ export default function ArtistDetail() {
|
||||
const navigate = useNavigate();
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
@@ -59,17 +61,45 @@ export default function ArtistDetail() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
let ownAlbumIds: Set<string>;
|
||||
getArtist(id).then(artistData => {
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
ownAlbumIds = new Set(artistData.albums.map(a => a.id));
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => [])
|
||||
getTopSongs(artistData.artist.name).catch(() => []),
|
||||
search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })),
|
||||
]);
|
||||
}).then(([artistInfo, songsData]) => {
|
||||
}).then(([artistInfo, songsData, searchResults]) => {
|
||||
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
|
||||
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
|
||||
|
||||
const featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
|
||||
);
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
albumMap.set(song.albumId, {
|
||||
id: song.albumId,
|
||||
name: song.album,
|
||||
artist: song.albumArtist ?? '',
|
||||
artistId: '',
|
||||
coverArt: song.coverArt,
|
||||
songCount: 1,
|
||||
duration: song.duration,
|
||||
year: song.year,
|
||||
});
|
||||
} else {
|
||||
const a = albumMap.get(song.albumId)!;
|
||||
a.songCount++;
|
||||
a.duration += song.duration;
|
||||
}
|
||||
});
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
@@ -77,15 +107,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 +217,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>
|
||||
)}
|
||||
@@ -312,6 +337,18 @@ export default function ArtistDetail() {
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||
)}
|
||||
|
||||
{/* Also Featured On */}
|
||||
{featuredAlbums.length > 0 && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.featuredOn')}
|
||||
</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function Artists() {
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { pingWithCredentials } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<img src="/logo.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
|
||||
<img src="/logo-psysonic.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
|
||||
);
|
||||
|
||||
export default function Login() {
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Play, Shuffle, Star, Music, Save, FolderOpen, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import {
|
||||
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
||||
getPlaylists, getPlaylist, createPlaylist, deletePlaylist,
|
||||
getAlbum, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDuration(secs: number): string {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return h > 0
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function renderStars(rating?: number) {
|
||||
if (!rating) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '3px', alignItems: 'center' }}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Star
|
||||
key={i}
|
||||
size={14}
|
||||
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--text-muted)'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blurred background (crossfade on track change) ───────────────────────────
|
||||
|
||||
const NpBg = memo(function NpBg({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const nextId = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
const id = nextId.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 30);
|
||||
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 700);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="np-bg-wrap">
|
||||
{layers.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className="np-bg-layer"
|
||||
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
|
||||
/>
|
||||
))}
|
||||
<div className="np-orb np-orb-1" />
|
||||
<div className="np-orb np-orb-2" />
|
||||
<div className="np-orb np-orb-3" />
|
||||
<div className="np-bg-overlay" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Modals (reused from QueuePanel) ──────────────────────────────────────────
|
||||
|
||||
function SavePlaylistModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.savePlaylist')}</h3>
|
||||
<input
|
||||
type="text" className="live-search-field"
|
||||
placeholder={t('queue.playlistName')} value={name}
|
||||
onChange={e => setName(e.target.value)} autoFocus
|
||||
onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
|
||||
style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t('queue.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={() => name.trim() && onSave(name.trim())}>{t('queue.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void; onLoad: (id: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetch = () => {
|
||||
setLoading(true);
|
||||
getPlaylists().then(d => { setPlaylists(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { fetch(); }, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(t('queue.deleteConfirm', { name }))) { await deletePlaylist(id); fetch(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>{t('queue.loading')}</p>
|
||||
: playlists.length === 0 ? <p style={{ color: 'var(--text-muted)' }}>{t('queue.noPlaylists')}</p>
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 300, overflowY: 'auto' }}>
|
||||
{playlists.map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--ctp-surface1)', borderRadius: 'var(--radius-md)' }}>
|
||||
<span style={{ fontWeight: 500 }} className="truncate">{p.name}</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button className="nav-btn" onClick={() => onLoad(p.id)} style={{ width: 28, height: 28, background: 'transparent' }}><Play size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} style={{ width: 28, height: 28, background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
let _dragFromIdx: number | null = null;
|
||||
|
||||
export default function NowPlaying() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||
const removeTrack = usePlayerStore(s => s.removeTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
const setQueueVisible = usePlayerStore(s => s.setQueueVisible);
|
||||
|
||||
// Hide queue panel while on this page, restore on leave
|
||||
useEffect(() => {
|
||||
const wasVisible = usePlayerStore.getState().isQueueVisible;
|
||||
if (wasVisible) setQueueVisible(false);
|
||||
return () => { if (wasVisible) setQueueVisible(true); };
|
||||
}, [setQueueVisible]);
|
||||
|
||||
// Extra song metadata (genre) fetched via getSong
|
||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
||||
useEffect(() => {
|
||||
if (!currentTrack) { setSongMeta(null); return; }
|
||||
getSong(currentTrack.id).then(setSongMeta);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Favorite state
|
||||
const [starred, setStarred] = useState(false);
|
||||
useEffect(() => {
|
||||
setStarred(!!songMeta?.starred);
|
||||
}, [songMeta]);
|
||||
const toggleStar = async () => {
|
||||
if (!currentTrack) return;
|
||||
if (starred) { await unstar(currentTrack.id, 'song'); setStarred(false); }
|
||||
else { await star(currentTrack.id, 'song'); setStarred(true); }
|
||||
};
|
||||
|
||||
// Cover / background
|
||||
const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
// Queue drag-and-drop
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const isDraggingInternalRef = useRef(false);
|
||||
const draggedIdxRef = useRef<number | null>(null);
|
||||
const dragOverIdxRef = useRef<number | null>(null);
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onDragStart = (e: React.DragEvent, index: number) => {
|
||||
isDraggingInternalRef.current = true;
|
||||
draggedIdxRef.current = index;
|
||||
_dragFromIdx = index;
|
||||
setDraggedIdx(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
|
||||
};
|
||||
const onDragEnterItem = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
|
||||
};
|
||||
const onDragOverItem = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
|
||||
dragOverIdxRef.current = index;
|
||||
setDragOverIdx(index);
|
||||
};
|
||||
const onDragEnd = () => {
|
||||
setDraggedIdx(null); setDragOverIdx(null);
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null; dragOverIdxRef.current = null;
|
||||
};
|
||||
const onDropQueue = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null; dragOverIdxRef.current = null;
|
||||
setDraggedIdx(null); setDragOverIdx(null);
|
||||
|
||||
let parsedData: any = null;
|
||||
try { const raw = e.dataTransfer.getData('text/plain'); if (raw) parsedData = JSON.parse(raw); } catch {}
|
||||
|
||||
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
|
||||
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
|
||||
_dragFromIdx = null;
|
||||
let toIdx = queue.length;
|
||||
if (queueListRef.current) {
|
||||
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const rect = items[i].getBoundingClientRect();
|
||||
if (e.clientY < rect.top + rect.height / 2) { toIdx = parseInt(items[i].dataset.queueIdx!); break; }
|
||||
}
|
||||
}
|
||||
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
|
||||
return;
|
||||
}
|
||||
_dragFromIdx = null;
|
||||
if (!parsedData) return;
|
||||
if (parsedData.type === 'song') enqueue([parsedData.track]);
|
||||
else if (parsedData.type === 'album') {
|
||||
const albumData = await getAlbum(parsedData.id);
|
||||
const tracks: Track[] = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}
|
||||
};
|
||||
|
||||
// Playlist modals
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="np-page">
|
||||
{/* ── Hero ── */}
|
||||
<div className="np-hero">
|
||||
<NpBg url={resolvedCover ?? ''} />
|
||||
|
||||
<div className="np-hero-content">
|
||||
{currentTrack ? (
|
||||
<>
|
||||
{/* Cover + glow */}
|
||||
<div className="np-cover-wrap">
|
||||
{resolvedCover && (
|
||||
<img src={resolvedCover} alt="" className="np-cover-glow" aria-hidden="true" />
|
||||
)}
|
||||
{resolvedCover
|
||||
? <img src={resolvedCover} alt="" className="np-cover" />
|
||||
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="np-info">
|
||||
<div className="np-title">{currentTrack.title}</div>
|
||||
|
||||
<div className="np-artist-album">
|
||||
<span
|
||||
className="np-link"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
>{currentTrack.artist}</span>
|
||||
<span className="np-sep">·</span>
|
||||
<span
|
||||
className="np-link"
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
>{currentTrack.album}</span>
|
||||
{currentTrack.year && <><span className="np-sep">·</span><span>{currentTrack.year}</span></>}
|
||||
</div>
|
||||
|
||||
<div className="np-tech-row">
|
||||
{songMeta?.genre && <span className="np-badge">{songMeta.genre}</span>}
|
||||
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
|
||||
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
|
||||
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
<button
|
||||
onClick={toggleStar}
|
||||
className="np-star-btn"
|
||||
title={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Star size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="np-empty-state">
|
||||
<Music size={48} style={{ opacity: 0.3 }} />
|
||||
<p>{t('nowPlaying.nothingPlaying')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Queue list ── */}
|
||||
<div className="np-queue-section">
|
||||
<div className="np-queue-header">
|
||||
<div>
|
||||
<h2 className="np-queue-title">{t('queue.title')}</h2>
|
||||
{queue.length > 0 && (
|
||||
<div className="np-queue-meta">
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {formatDuration(totalSecs)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-queue-actions">
|
||||
<button onClick={() => shuffleQueue()} className="np-action-btn" title={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<Shuffle size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" title={t('queue.save')} disabled={queue.length === 0}>
|
||||
<Save size={15} />
|
||||
</button>
|
||||
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" title={t('queue.load')}>
|
||||
<FolderOpen size={15} />
|
||||
</button>
|
||||
<button onClick={clearQueue} className="np-action-btn" title={t('queue.clear')} disabled={queue.length === 0}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="np-queue-list"
|
||||
ref={queueListRef}
|
||||
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDrop={onDropQueue}
|
||||
>
|
||||
{queue.length === 0 ? (
|
||||
<div className="queue-empty">{t('queue.emptyQueue')}</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isActive = idx === queueIndex;
|
||||
const isDragging = draggedIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isDragging) dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
|
||||
else if (isDragOver && draggedIdx !== null) {
|
||||
dragStyle = draggedIdx > idx
|
||||
? { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }
|
||||
: { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`np-queue-item ${isActive ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onContextMenu={e => { e.preventDefault(); usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx); }}
|
||||
draggable
|
||||
onDragStart={e => onDragStart(e, idx)}
|
||||
onDragEnter={e => onDragEnterItem(e)}
|
||||
onDragOver={e => onDragOverItem(e, idx)}
|
||||
onDragEnd={onDragEnd}
|
||||
style={dragStyle}
|
||||
>
|
||||
<div className="np-queue-num">
|
||||
{isActive
|
||||
? <Play size={12} fill="var(--accent)" color="var(--accent)" />
|
||||
: <span>{idx + 1}</span>
|
||||
}
|
||||
</div>
|
||||
<div className="np-queue-item-info">
|
||||
<div className={`np-queue-item-title truncate ${isActive ? 'np-queue-item-active' : ''}`}>{track.title}</div>
|
||||
<div className="np-queue-item-artist truncate">{track.artist} · {track.album}</div>
|
||||
</div>
|
||||
<div className="np-queue-item-duration">{formatTime(track.duration)}</div>
|
||||
<button
|
||||
className="np-queue-remove"
|
||||
onClick={e => { e.stopPropagation(); removeTrack(idx); }}
|
||||
title={t('queue.remove')}
|
||||
><X size={13} /></button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveModalOpen && (
|
||||
<SavePlaylistModal
|
||||
onClose={() => setSaveModalOpen(false)}
|
||||
onSave={async name => {
|
||||
try { await createPlaylist(name, queue.map(t => t.id)); setSaveModalOpen(false); }
|
||||
catch (e) { console.error(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{loadModalOpen && (
|
||||
<LoadPlaylistModal
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async id => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const tracks: Track[] = data.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
|
||||
setLoadModalOpen(false);
|
||||
} catch (e) { console.error(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play
|
||||
} from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
@@ -10,9 +12,12 @@ import { useThemeStore } from '../store/themeStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Equalizer from '../components/Equalizer';
|
||||
|
||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'server' | 'about';
|
||||
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
|
||||
@@ -77,6 +82,7 @@ export default function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('playback');
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
@@ -144,367 +150,593 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
|
||||
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('settings.title')}</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('settings.title')}</h1>
|
||||
|
||||
{/* Language */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Globe size={18} />
|
||||
<h2>{t('settings.language')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
<option value="en">{t('settings.languageEn')}</option>
|
||||
<option value="de">{t('settings.languageDe')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Tab navigation */}
|
||||
<nav className="settings-tabs" aria-label="Settings navigation">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Theme */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={theme.theme}
|
||||
onChange={(e) => theme.setTheme(e.target.value as any)}
|
||||
aria-label={t('settings.theme')}
|
||||
>
|
||||
<option value="mocha">Catppuccin Mocha</option>
|
||||
<option value="macchiato">Catppuccin Macchiato</option>
|
||||
<option value="frappe">Catppuccin Frappé</option>
|
||||
<option value="latte">Catppuccin Latte</option>
|
||||
<option value="nord">Nord · Polar Night</option>
|
||||
<option value="nord-snowstorm">Nord · Snowstorm</option>
|
||||
<option value="nord-frost">Nord · Frost</option>
|
||||
<option value="nord-aurora">Nord · Aurora</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* ── Playback ─────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'playback' && (
|
||||
<>
|
||||
{/* Equalizer */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
<h2>{t('settings.eqTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<Equalizer />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Servers */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Server size={18} />
|
||||
<h2>{t('settings.servers')}</h2>
|
||||
</div>
|
||||
{/* Replay Gain + Crossfade + Gapless */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.playbackTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{/* Replay Gain */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.replayGain')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.replayGain')}>
|
||||
<input type="checkbox" checked={auth.replayGainEnabled} onChange={e => auth.setReplayGainEnabled(e.target.checked)} id="replay-gain-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.replayGainEnabled && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
|
||||
<button
|
||||
className={`btn ${auth.replayGainMode === 'track' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setReplayGainMode('track')}
|
||||
>
|
||||
{t('settings.replayGainTrack')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.replayGainMode === 'album' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setReplayGainMode('album')}
|
||||
>
|
||||
{t('settings.replayGainAlbum')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{auth.servers.length === 0 && !showAddForm ? (
|
||||
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
{t('settings.noServers')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{auth.servers.map(srv => {
|
||||
const isActive = srv.id === auth.activeServerId;
|
||||
const status = connStatus[srv.id];
|
||||
return (
|
||||
<div key={srv.id} className="settings-card" style={{ border: isActive ? '1px solid var(--accent)' : undefined }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{srv.name || srv.url}</span>
|
||||
{isActive && (
|
||||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}>
|
||||
{t('settings.serverActive')}
|
||||
</span>
|
||||
)}
|
||||
<div className="divider" />
|
||||
|
||||
{/* Crossfade */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.crossfade')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
|
||||
<input type="checkbox" checked={auth.crossfadeEnabled} onChange={e => auth.setCrossfadeEnabled(e.target.checked)} id="crossfade-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.crossfadeEnabled && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={auth.crossfadeSecs}
|
||||
onChange={e => auth.setCrossfadeSecs(Number(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 28 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* Gapless */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.gapless')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.gapless')}>
|
||||
<input type="checkbox" checked={auth.gaplessEnabled} onChange={e => auth.setGaplessEnabled(e.target.checked)} id="gapless-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Scrobbling */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lfmTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
<p style={{ marginBottom: '0.5rem' }}>
|
||||
{t('settings.lfmDesc1')}{' '}
|
||||
<strong>{t('settings.lfmDesc1NavidromeWebplayer')}</strong>
|
||||
{' '}{t('settings.lfmDesc1b')}
|
||||
</p>
|
||||
<p>{t('settings.lfmDesc2')}</p>
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ marginTop: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Library ──────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'library' && (
|
||||
<>
|
||||
{/* Cache */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.cacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={2000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
id="cache-size-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Random Mix */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Shuffle size={18} />
|
||||
<h2>{t('settings.randomMixTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.randomMixBlacklistDesc')}
|
||||
</p>
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||
{auth.customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
auth.customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||
aria-label={`Remove ${genre}`}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>
|
||||
{t('settings.randomMixBlacklistAdd')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'appearance' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={theme.theme}
|
||||
onChange={(e) => theme.setTheme(e.target.value as any)}
|
||||
aria-label={t('settings.theme')}
|
||||
>
|
||||
<option value="mocha">Catppuccin Mocha</option>
|
||||
<option value="macchiato">Catppuccin Macchiato</option>
|
||||
<option value="frappe">Catppuccin Frappé</option>
|
||||
<option value="latte">Catppuccin Latte</option>
|
||||
<option value="nord">Nord · Polar Night</option>
|
||||
<option value="nord-snowstorm">Nord · Snowstorm</option>
|
||||
<option value="nord-frost">Nord · Frost</option>
|
||||
<option value="nord-aurora">Nord · Aurora</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Globe size={18} />
|
||||
<h2>{t('settings.language')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
<option value="en">{t('settings.languageEn')}</option>
|
||||
<option value="de">{t('settings.languageDe')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Server ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'server' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Server size={18} />
|
||||
<h2>{t('settings.servers')}</h2>
|
||||
</div>
|
||||
|
||||
{auth.servers.length === 0 && !showAddForm ? (
|
||||
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
{t('settings.noServers')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{auth.servers.map(srv => {
|
||||
const isActive = srv.id === auth.activeServerId;
|
||||
const status = connStatus[srv.id];
|
||||
return (
|
||||
<div key={srv.id} className="settings-card" style={{ border: isActive ? '1px solid var(--accent)' : undefined }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{srv.name || srv.url}</span>
|
||||
{isActive && (
|
||||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}>
|
||||
{t('settings.serverActive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{srv.username}@{srv.url}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center' }}>
|
||||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||||
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
|
||||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => testConnection(srv)}
|
||||
disabled={status === 'testing'}
|
||||
>
|
||||
<Wifi size={13} />
|
||||
{t('settings.testBtn')}
|
||||
</button>
|
||||
{!isActive && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => switchToServer(srv)}
|
||||
disabled={status === 'testing'}
|
||||
id={`settings-use-server-${srv.id}`}
|
||||
>
|
||||
{t('settings.useServer')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ color: 'var(--danger)', padding: '4px 8px' }}
|
||||
onClick={() => deleteServer(srv)}
|
||||
data-tooltip={t('settings.deleteServer')}
|
||||
id={`settings-delete-server-${srv.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{srv.username}@{srv.url}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center' }}>
|
||||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||||
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
|
||||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => testConnection(srv)}
|
||||
disabled={status === 'testing'}
|
||||
>
|
||||
<Wifi size={13} />
|
||||
{t('settings.testBtn')}
|
||||
</button>
|
||||
{!isActive && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => switchToServer(srv)}
|
||||
disabled={status === 'testing'}
|
||||
id={`settings-use-server-${srv.id}`}
|
||||
>
|
||||
{t('settings.useServer')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ color: 'var(--danger)', padding: '4px 8px' }}
|
||||
onClick={() => deleteServer(srv)}
|
||||
data-tooltip={t('settings.deleteServer')}
|
||||
id={`settings-delete-server-${srv.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm ? (
|
||||
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
|
||||
<Plus size={16} /> {t('settings.addServer')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Downloads + Tray */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.trayTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.trayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.trayTitle')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
|
||||
{auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm ? (
|
||||
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
|
||||
<Plus size={16} /> {t('settings.addServer')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Last.fm */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lfmTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
<p style={{ marginBottom: '0.5rem' }}>
|
||||
{t('settings.lfmDesc1')}{' '}
|
||||
<strong>{t('settings.lfmDesc1NavidromeWebplayer')}</strong>
|
||||
{' '}{t('settings.lfmDesc1b')}
|
||||
</p>
|
||||
<p>{t('settings.lfmDesc2')}</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-toggle-row" style={{ marginTop: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* App Behavior */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.trayTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.trayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.trayTitle')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.cacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={2000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
id="cache-size-slider"
|
||||
/>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
|
||||
{auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={pickDownloadFolder} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={pickDownloadFolder} id="settings-download-folder-btn" style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── About ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'about' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Info size={18} />
|
||||
<h2>{t('settings.aboutTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card settings-about">
|
||||
<div className="settings-about-header">
|
||||
<img src="/logo-psysonic.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
Psysonic
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.aboutVersion')} {appVersion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
|
||||
{t('settings.aboutDesc')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
|
||||
{t('settings.aboutFeatures')}
|
||||
</p>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: 13 }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutLicense')}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutLicenseText')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
|
||||
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('settings.aboutRepo')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ChangelogSection />
|
||||
|
||||
<section className="settings-section">
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
|
||||
<LogOut size={16} /> {t('settings.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Random Mix */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Shuffle size={18} />
|
||||
<h2>{t('settings.randomMixTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{/* Description */}
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.randomMixBlacklistDesc')}
|
||||
</p>
|
||||
|
||||
{/* Custom blacklist chips */}
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||
{auth.customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
auth.customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||
aria-label={`Remove ${genre}`}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add input */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>
|
||||
{t('settings.randomMixBlacklistAdd')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
{/* Hardcoded list */}
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Logout */}
|
||||
<section className="settings-section">
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
|
||||
<LogOut size={16} /> {t('settings.logout')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* About */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Info size={18} />
|
||||
<h2>{t('settings.aboutTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card settings-about">
|
||||
<div className="settings-about-header">
|
||||
<img src="/logo.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
Psysonic
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.aboutVersion')} {appVersion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
|
||||
{t('settings.aboutDesc')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
|
||||
{t('settings.aboutFeatures')}
|
||||
</p>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: 13 }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutLicense')}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutLicenseText')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
|
||||
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('settings.aboutRepo')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Changelog renderer ───────────────────────────────────────────────────────
|
||||
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
// Splits on **bold**, *italic*, `code` and renders each part.
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**'))
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('*') && part.endsWith('*'))
|
||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('`') && part.endsWith('`'))
|
||||
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
function ChangelogSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const versions = useMemo(() => {
|
||||
const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## ['));
|
||||
return blocks.map(block => {
|
||||
const lines = block.split('\n');
|
||||
const headerLine = lines[0]; // e.g. "## [1.5.0] - 2026-03-18"
|
||||
const versionMatch = headerLine.match(/## \[([^\]]+)\]/);
|
||||
const dateMatch = headerLine.match(/- (\d{4}-\d{2}-\d{2})/);
|
||||
const version = versionMatch?.[1] ?? '';
|
||||
const date = dateMatch?.[1] ?? '';
|
||||
|
||||
// Parse the rest into rendered lines
|
||||
const body = lines.slice(1).join('\n').trim();
|
||||
return { version, date, body };
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Info size={18} />
|
||||
<h2>{t('settings.changelog')}</h2>
|
||||
</div>
|
||||
<div className="changelog-list">
|
||||
{versions.map(({ version, date, body }) => (
|
||||
<details key={version} className="changelog-entry" open={version === appVersion}>
|
||||
<summary className="changelog-summary">
|
||||
<span className="changelog-version">v{version}</span>
|
||||
<span className="changelog-date">{date}</span>
|
||||
</summary>
|
||||
<div className="changelog-body">
|
||||
{body.split('\n').map((line, i) => {
|
||||
if (line.startsWith('### ')) {
|
||||
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
||||
}
|
||||
if (line.startsWith('#### ')) {
|
||||
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
||||
}
|
||||
if (line.startsWith('- ')) {
|
||||
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
||||
}
|
||||
if (line.trim() === '') return null;
|
||||
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ interface AuthState {
|
||||
downloadFolder: string;
|
||||
excludeAudiobooks: boolean;
|
||||
customGenreBlacklist: string[];
|
||||
replayGainEnabled: boolean;
|
||||
replayGainMode: 'track' | 'album';
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
|
||||
// Status
|
||||
isLoggedIn: boolean;
|
||||
@@ -48,6 +53,11 @@ interface AuthState {
|
||||
setDownloadFolder: (v: string) => void;
|
||||
setExcludeAudiobooks: (v: boolean) => void;
|
||||
setCustomGenreBlacklist: (v: string[]) => void;
|
||||
setReplayGainEnabled: (v: boolean) => void;
|
||||
setReplayGainMode: (v: 'track' | 'album') => void;
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
logout: () => void;
|
||||
|
||||
// Derived
|
||||
@@ -74,6 +84,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
downloadFolder: '',
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: [],
|
||||
replayGainEnabled: false,
|
||||
replayGainMode: 'track',
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
@@ -117,6 +132,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
|
||||
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
|
||||
setReplayGainEnabled: (v) => set({ replayGainEnabled: v }),
|
||||
setReplayGainMode: (v) => set({ replayGainMode: v }),
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
|
||||
logout: () => set({ isLoggedIn: false }),
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Module-level callback — not in Zustand state to avoid serialization issues
|
||||
let _resolve: ((folder: string | null) => void) | null = null;
|
||||
|
||||
interface DownloadModalStore {
|
||||
isOpen: boolean;
|
||||
folder: string;
|
||||
remember: boolean;
|
||||
requestFolder: () => Promise<string | null>;
|
||||
setFolder: (f: string) => void;
|
||||
setRemember: (r: boolean) => void;
|
||||
confirm: (setDownloadFolder: (v: string) => void) => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export const useDownloadModalStore = create<DownloadModalStore>((set, get) => ({
|
||||
isOpen: false,
|
||||
folder: '',
|
||||
remember: false,
|
||||
|
||||
requestFolder: () =>
|
||||
new Promise<string | null>(resolve => {
|
||||
_resolve = resolve;
|
||||
set({ isOpen: true, folder: '', remember: false });
|
||||
}),
|
||||
|
||||
setFolder: (folder) => set({ folder }),
|
||||
setRemember: (remember) => set({ remember }),
|
||||
|
||||
confirm: (setDownloadFolder) => {
|
||||
const { folder, remember } = get();
|
||||
if (!folder) return;
|
||||
if (remember) setDownloadFolder(folder);
|
||||
_resolve?.(folder);
|
||||
_resolve = null;
|
||||
set({ isOpen: false });
|
||||
},
|
||||
|
||||
cancel: () => {
|
||||
_resolve?.(null);
|
||||
_resolve = null;
|
||||
set({ isOpen: false });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,113 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export const EQ_BANDS = [
|
||||
{ freq: 31, label: '31' },
|
||||
{ freq: 62, label: '62' },
|
||||
{ freq: 125, label: '125' },
|
||||
{ freq: 250, label: '250' },
|
||||
{ freq: 500, label: '500' },
|
||||
{ freq: 1000, label: '1k' },
|
||||
{ freq: 2000, label: '2k' },
|
||||
{ freq: 4000, label: '4k' },
|
||||
{ freq: 8000, label: '8k' },
|
||||
{ freq: 16000, label: '16k' },
|
||||
];
|
||||
|
||||
export interface EqPreset {
|
||||
name: string;
|
||||
gains: number[];
|
||||
builtin: boolean;
|
||||
}
|
||||
|
||||
export const BUILTIN_PRESETS: EqPreset[] = [
|
||||
{ name: 'Flat', builtin: true, gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
|
||||
{ name: 'Bass Boost', builtin: true, gains: [6, 5, 4, 2, 0, 0, 0, 0, 0, 0] },
|
||||
{ name: 'Treble Boost',builtin: true, gains: [0, 0, 0, 0, 0, 0, 2, 3, 4, 5] },
|
||||
{ name: 'Rock', builtin: true, gains: [4, 3, 1, 0, -1, -1, 1, 3, 4, 4] },
|
||||
{ name: 'Pop', builtin: true, gains: [-2, -1, 0, 2, 4, 4, 2, 0, -1, -2] },
|
||||
{ name: 'Jazz', builtin: true, gains: [4, 3, 1, 2, -1, -1, 0, 1, 2, 3] },
|
||||
{ name: 'Classical', builtin: true, gains: [5, 4, 3, 2, -2, -2, 0, 2, 3, 4] },
|
||||
{ name: 'Electronic', builtin: true, gains: [5, 4, 1, 0, -2, 1, 1, 2, 4, 5] },
|
||||
{ name: 'Vocal', builtin: true, gains: [-2, -2, 0, 3, 5, 5, 3, 1, -1, -2] },
|
||||
{ name: 'Acoustic', builtin: true, gains: [3, 2, 2, 2, 0, 0, 1, 2, 2, 3] },
|
||||
];
|
||||
|
||||
interface EqState {
|
||||
gains: number[]; // 10 values, -12 to +12 dB
|
||||
enabled: boolean;
|
||||
activePreset: string | null;
|
||||
customPresets: EqPreset[];
|
||||
|
||||
setBandGain: (index: number, gain: number) => void;
|
||||
setEnabled: (v: boolean) => void;
|
||||
applyPreset: (name: string) => void;
|
||||
saveCustomPreset: (name: string) => void;
|
||||
deleteCustomPreset: (name: string) => void;
|
||||
syncToRust: () => void;
|
||||
}
|
||||
|
||||
function syncEq(gains: number[], enabled: boolean) {
|
||||
invoke('audio_set_eq', { gains: gains.map(g => g), enabled }).catch(() => {});
|
||||
}
|
||||
|
||||
export const useEqStore = create<EqState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
enabled: false,
|
||||
activePreset: 'Flat',
|
||||
customPresets: [],
|
||||
|
||||
setBandGain: (index, gain) => {
|
||||
const clamped = Math.max(-12, Math.min(12, gain));
|
||||
const gains = [...get().gains];
|
||||
gains[index] = clamped;
|
||||
set({ gains, activePreset: null });
|
||||
syncEq(gains, get().enabled);
|
||||
},
|
||||
|
||||
setEnabled: (v) => {
|
||||
set({ enabled: v });
|
||||
syncEq(get().gains, v);
|
||||
},
|
||||
|
||||
applyPreset: (name) => {
|
||||
const all = [...BUILTIN_PRESETS, ...get().customPresets];
|
||||
const preset = all.find(p => p.name === name);
|
||||
if (!preset) return;
|
||||
set({ gains: [...preset.gains], activePreset: name });
|
||||
syncEq(preset.gains, get().enabled);
|
||||
},
|
||||
|
||||
saveCustomPreset: (name) => {
|
||||
const gains = [...get().gains];
|
||||
const existing = get().customPresets.filter(p => p.name !== name);
|
||||
set({ customPresets: [...existing, { name, gains, builtin: false }], activePreset: name });
|
||||
},
|
||||
|
||||
deleteCustomPreset: (name) => {
|
||||
set(s => ({
|
||||
customPresets: s.customPresets.filter(p => p.name !== name),
|
||||
activePreset: s.activePreset === name ? null : s.activePreset,
|
||||
}));
|
||||
},
|
||||
|
||||
syncToRust: () => {
|
||||
const { gains, enabled } = get();
|
||||
syncEq(gains, enabled);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'psysonic-eq',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (s) => ({
|
||||
gains: s.gains,
|
||||
enabled: s.enabled,
|
||||
activePreset: s.activePreset,
|
||||
customPresets: s.customPresets,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -19,6 +19,30 @@ export interface Track {
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
userRating?: number;
|
||||
replayGainTrackDb?: number;
|
||||
replayGainAlbumDb?: number;
|
||||
replayGainPeak?: number;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
return {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt,
|
||||
track: song.track,
|
||||
year: song.year,
|
||||
bitRate: song.bitRate,
|
||||
suffix: song.suffix,
|
||||
userRating: song.userRating,
|
||||
replayGainTrackDb: song.replayGain?.trackGain,
|
||||
replayGainAlbumDb: song.replayGain?.albumGain,
|
||||
replayGainPeak: song.replayGain?.trackPeak,
|
||||
};
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
@@ -47,6 +71,7 @@ interface PlayerState {
|
||||
|
||||
isQueueVisible: boolean;
|
||||
toggleQueue: () => void;
|
||||
setQueueVisible: (v: boolean) => void;
|
||||
|
||||
isFullscreenOpen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
@@ -124,6 +149,20 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
const { scrobblingEnabled } = useAuthStore.getState();
|
||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||
}
|
||||
|
||||
// Gapless preload: buffer next track when 30s remain
|
||||
const { gaplessEnabled } = useAuthStore.getState();
|
||||
if (gaplessEnabled && dur - current_time < 30 && dur - current_time > 0) {
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextTrack = repeatMode === 'one'
|
||||
? track
|
||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||
if (nextTrack && nextTrack.id !== track.id) {
|
||||
const nextUrl = buildStreamUrl(nextTrack.id);
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
@@ -166,7 +205,20 @@ export function initAudioListeners(): () => void {
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
];
|
||||
|
||||
// Initial sync of crossfade settings to Rust audio engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState();
|
||||
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||
|
||||
// Keep crossfade settings in sync whenever auth store changes.
|
||||
const unsubAuth = useAuthStore.subscribe((state) => {
|
||||
invoke('audio_set_crossfade', {
|
||||
enabled: state.crossfadeEnabled,
|
||||
secs: state.crossfadeSecs,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
};
|
||||
}
|
||||
@@ -198,6 +250,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
})),
|
||||
|
||||
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
|
||||
setQueueVisible: (v: boolean) => set({ isQueueVisible: v }),
|
||||
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
|
||||
|
||||
toggleRepeat: () => set(state => {
|
||||
@@ -236,10 +289,17 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
});
|
||||
|
||||
const url = buildStreamUrl(track.id);
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
@@ -275,10 +335,17 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const gen = ++playGeneration;
|
||||
const vol = get().volume;
|
||||
set({ isPlaying: true });
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = authStateCold.replayGainEnabled
|
||||
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
url: buildStreamUrl(currentTrack.id),
|
||||
volume: vol,
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
|
||||
@@ -134,6 +134,45 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Now Playing nav link */
|
||||
.nav-link-nowplaying {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-link-nowplaying:hover {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-link-nowplaying.active {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-link-nowplaying svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-np-icon-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-np-dot {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -4px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: np-dot-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes np-dot-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.7); }
|
||||
}
|
||||
|
||||
/* Collapsed Sidebar Styles */
|
||||
.sidebar.collapsed .sidebar-brand {
|
||||
justify-content: center;
|
||||
@@ -450,6 +489,74 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-eq-btn {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.player-eq-btn:hover,
|
||||
.player-eq-btn.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── EQ Popup ─── */
|
||||
.eq-popup-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.eq-popup {
|
||||
position: fixed;
|
||||
z-index: 201;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(700px, 92vw);
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.eq-popup .eq-wrap {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.eq-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.eq-popup-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.eq-popup-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.eq-popup-close:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-app);
|
||||
}
|
||||
|
||||
/* ─── Queue Panel ─── */
|
||||
.queue-panel {
|
||||
@@ -478,15 +585,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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||