Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59115a09d2 | |||
| 18199a1f8a | |||
| d4e44199e9 | |||
| 9b4eb0982c | |||
| 8ec642f368 | |||
| 73f836b2ee | |||
| af18aef42a | |||
| d3ffa30bf5 | |||
| f666f84479 |
@@ -22,20 +22,39 @@ jobs:
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
# Extract the block between ## [VERSION] and the next ## heading
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
# Store multiline output
|
||||
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF" >> $GITHUB_OUTPUT
|
||||
echo "$BODY" >> $GITHUB_OUTPUT
|
||||
echo "$EOF" >> $GITHUB_OUTPUT
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
|
||||
with:
|
||||
script: |
|
||||
const tag = `app-v${process.env.PACKAGE_VERSION}`;
|
||||
const body = process.env.CHANGELOG_BODY || 'See the assets to download this version and install.';
|
||||
try {
|
||||
const { data } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: data.id,
|
||||
body,
|
||||
});
|
||||
return data.id;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
@@ -45,7 +64,7 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body: 'See the assets to download this version and install.',
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
});
|
||||
@@ -89,7 +108,7 @@ jobs:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -98,15 +117,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev \
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-libav
|
||||
|
||||
- name: install linuxdeploy gstreamer plugin
|
||||
run: |
|
||||
wget -q "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
|
||||
-O /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
libasound2-dev
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
@@ -120,21 +131,7 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: build
|
||||
run: npm run tauri:build
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
|
||||
- name: patch AppImage AppRun
|
||||
run: |
|
||||
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
|
||||
echo "Patching: $APPIMAGE"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
|
||||
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
|
||||
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
|
||||
-O appimagetool
|
||||
chmod +x appimagetool
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool squashfs-root "$APPIMAGE"
|
||||
rm -rf squashfs-root appimagetool
|
||||
run: npm run tauri:build -- --bundles deb,rpm
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
@@ -142,5 +139,5 @@ jobs:
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.AppImage" -not -name "*.tar.gz" -o -name "*.deb" -o -name "*.rpm" \) \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
@@ -5,6 +5,163 @@ 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.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
|
||||
|
||||
#### AUR Package
|
||||
- Psysonic is now available on the **Arch User Repository** — Arch and CachyOS users can install via `yay -S psysonic` or `paru -S psysonic`. Builds from source using the system's own WebKitGTK, avoiding the EGL/Mesa compatibility issues that affected the AppImage on modern distros.
|
||||
|
||||
### Changed
|
||||
|
||||
#### App Icon
|
||||
- New app icon across all platforms (Windows, macOS, Linux, Android, iOS).
|
||||
|
||||
#### Linux Distribution
|
||||
- **AppImage removed**: The AppImage was fundamentally incompatible with non-Ubuntu distros (Arch, Fedora) due to bundled WebKitGTK conflicting with the system's Mesa/EGL. Linux users should use the `.deb` (Ubuntu/Debian), `.rpm` (Fedora/RHEL), or the new AUR package (Arch/CachyOS).
|
||||
|
||||
## [1.4.3] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Random Mix — Genre Mix
|
||||
- **Second "Play All" button removed**: The genre mix section had a redundant play button below the super-genre selector. The top-right button is now context-aware — it plays the genre mix when one is active, otherwise the regular mix.
|
||||
- **"Play All" disabled during genre mix loading**: The button now stays grayed out with a live progress counter (`n / 50`) until all songs are fully loaded. Clicking while the list was still building sent only the songs loaded so far.
|
||||
- **Over-fetching fixed**: Genre mix previously fetched up to 100+ songs and sliced to 50 at the end. Now the matched genre list is capped at 50 (randomly sampled when more match) so the total fetch stays close to 50 with no wasted server I/O.
|
||||
- **Regular mix cache-busting**: `getRandomSongs` requests now include a timestamp parameter, preventing browser/axios from returning a cached response and showing the same list on every remix.
|
||||
- **Display/state mismatch on remix**: Clicking "Mischen" now clears the current list immediately, ensuring the spinner is shown and the displayed songs always match what "Play All" would send.
|
||||
|
||||
#### Queue Panel
|
||||
- **Hover highlight lost on right-click**: Queue items now retain their hover highlight while a context menu is open for them (`.context-active` CSS class).
|
||||
- **Song count and total duration**: The queue header now shows the number of tracks and total runtime below the title (e.g. `12 tracks · 47:32`).
|
||||
|
||||
#### Context Menu
|
||||
- **"Favorite" option added for queue items**: Right-clicking a queue item now includes a "Favorite" option, consistent with the song context menu.
|
||||
|
||||
## [1.4.2] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Linux AppImage — Modern Distro Compatibility
|
||||
- **Build upgraded to Ubuntu 24.04**: The AppImage was previously built on Ubuntu 22.04 with WebKitGTK 2.36. On modern distros (CachyOS, Arch, etc.) with Mesa 25.x, `eglGetDisplay(EGL_DEFAULT_DISPLAY)` returns `EGL_BAD_PARAMETER` and aborts immediately because newer Mesa no longer accepts implicit platform detection. Building on Ubuntu 24.04 bundles WebKitGTK 2.44 which uses the correct `eglGetPlatformDisplay` API.
|
||||
- **`EGL_PLATFORM=x11` added to AppRun**: Additional safeguard that explicitly tells Mesa's EGL loader to use the X11 platform when the app is running under XWayland.
|
||||
|
||||
#### Shell — Update Link
|
||||
- `shell:allow-open` capability now includes a URL scope (`https://**`), fixing the update toast link that silently did nothing in Tauri v2 without an explicit allow-list.
|
||||
|
||||
## [1.4.1] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Random Albums — Performance & Memory
|
||||
- **Auto-refresh removed**: The 30-second auto-cycle timer caused 10 React state updates/second (progress bar interval) and a burst of 30 concurrent image fetches on every tick, eventually making the whole app unresponsive. The page now loads once on mount; use the manual refresh button to get a new selection.
|
||||
- **Concurrent fetch limit**: Image fetches are now capped at 5 simultaneous network requests (was unlimited — 30 at once on every refresh).
|
||||
- **Object URL memory leak**: The in-memory image cache now caps at 150 entries and revokes old object URLs via `URL.revokeObjectURL()` when evicting. Previously, object URLs accumulated without bound across the entire session.
|
||||
- **Dangling state updates**: `useCachedUrl` now uses a cancellation flag — if a component unmounts while a fetch is in flight (e.g. during a grid refresh), the resolved URL is discarded instead of calling `setState` on an unmounted component.
|
||||
|
||||
#### i18n
|
||||
- Page title "Neueste" on the New Releases page was hardcoded German. Now uses `t('sidebar.newReleases')`.
|
||||
|
||||
## [1.4.0] - 2026-03-16
|
||||
|
||||
### Added
|
||||
|
||||
#### Statistics Page — Upgraded
|
||||
- **Library overview**: Four stat cards at the top showing total Artists, Albums, Songs, and Genres — counts derived from the library in parallel.
|
||||
- **Recently Played**: Horizontal scroll row showing the last played albums with cover art.
|
||||
- **Most Played**: Ranked list of the most frequently played tracks.
|
||||
- **Highest Rated**: List of top-rated tracks by user star rating.
|
||||
- **Genre Chart**: Visual bar chart of the top genres by song and album count.
|
||||
|
||||
#### Playlists Page — Redesigned
|
||||
- Replaced the card grid with a clean list layout.
|
||||
- **Sort buttons**: Sort by Name, Tracks, or Duration — click again to toggle ascending/descending.
|
||||
- **Filter input**: Live search across playlist names.
|
||||
- Play and delete buttons appear on row hover.
|
||||
|
||||
#### Favorites — Songs Section Upgraded
|
||||
- Tracks now display in a full tracklist layout matching Album Detail: separate `#`, Title, Artist, and Duration columns with a header row.
|
||||
- Artist name is clickable and navigates to the artist page.
|
||||
- Right-click context menu on any track (Go to Album, Add to Queue, etc.).
|
||||
- **"Add all to queue"** button (`btn btn-surface`) next to the section title.
|
||||
|
||||
#### Context Menu — Go to Album
|
||||
- New **Go to Album** option (`Disc3` icon) added for `song` and `queue-item` context menu types.
|
||||
- Only shown when the song has a known `albumId`.
|
||||
|
||||
#### Queue Panel — Meta Box
|
||||
- Now shows: **Title** (no link) → **Artist** (linked to artist page) → **Album** (linked to album page).
|
||||
- Removed year display and the old title→album link.
|
||||
|
||||
#### Random Mix — Hover Persistence
|
||||
- Track row stays highlighted while its context menu is open via `.context-active` CSS class.
|
||||
- Highlight is cleared automatically when the context menu closes.
|
||||
|
||||
#### Artist Cards — Redesigned
|
||||
- `ArtistCardLocal` now matches `AlbumCard` exactly: no padding, full-width square cover via `aspect-ratio: 1`, name and meta below.
|
||||
- Uses `CachedImage` with `coverArtCacheKey` for proper IndexedDB caching.
|
||||
- Same `flex: 0 0 clamp(140px, 15vw, 180px)` sizing as album cards — artist cards are no longer oversized.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Random Albums — Cover Loading & Manual Refresh
|
||||
- **Removed `renderKey`**: The album grid was fully remounted on every refresh, restarting all 30 IndexedDB image lookups from scratch. Grid is now stable — only data changes, images stay cached.
|
||||
- **`loadingRef` guard**: Prevents concurrent fetch calls if the auto-cycle timer fires during a manual refresh.
|
||||
- **Timer race condition**: Manual refresh now calls `clearTimers()` before `load()`, eliminating the race where the auto-cycle timer fired mid-load.
|
||||
|
||||
#### Favorites — Artist Navigation
|
||||
- Arrow nav buttons in the Artists section now use the same CSS classes as the Albums section (`album-row-section`, `album-row-header`, `album-row-nav`) — consistent styling across both rows.
|
||||
|
||||
### Changed
|
||||
- **AlbumDetail** refactored into a thin orchestrator. Logic extracted into `AlbumHeader` (`src/components/AlbumHeader.tsx`) and `AlbumTrackList` (`src/components/AlbumTrackList.tsx`).
|
||||
- **German i18n**: "Queue" consistently translated as "Warteschlange" throughout — `queue.shuffle`, `favorites.enqueueAll`.
|
||||
|
||||
## [1.3.0] - 2026-03-15
|
||||
|
||||
### 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,21 +1,20 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2026 Psychotoxical
|
||||
Copyright (C) 2026 Psychotoxical
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The full license text is available at:
|
||||
https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<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>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/Psychotoxical/psysonic?style=flat-square&color=cba6f7"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) aesthetic.
|
||||
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
|
||||
|
||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
||||
|
||||
@@ -33,7 +34,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🔗 **In-App Browser**: Last.fm and Wikipedia links on artist pages open in a dedicated native window — no need to leave the app.
|
||||
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||
|
||||
@@ -47,7 +47,11 @@ Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) p
|
||||
|
||||
- **Windows**: `.exe` or `.msi`
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
- **Linux**: `.AppImage` or `.deb`
|
||||
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
|
||||
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
|
||||
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
|
||||
|
||||
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -94,4 +98,6 @@ Contributions are completely welcome! Whether it is translating the app into a n
|
||||
|
||||
## 📄 License
|
||||
|
||||
Distributed under the MIT License. See `LICENSE` for more information.
|
||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
||||
|
||||
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
|
||||
|
||||
@@ -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.3.0",
|
||||
"version": "1.5.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",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
src/
|
||||
pkg/
|
||||
*.tar.zst
|
||||
*.tar.gz
|
||||
@@ -0,0 +1,65 @@
|
||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||
pkgname=psysonic
|
||||
pkgver=1.4.5
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Psychotoxical/psysonic"
|
||||
license=('GPL-3.0-only')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
'libayatana-appindicator'
|
||||
'openssl'
|
||||
'alsa-lib'
|
||||
)
|
||||
makedepends=(
|
||||
'npm'
|
||||
'rust'
|
||||
'cargo'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
npm install
|
||||
npm run tauri:build -- --no-bundle
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
|
||||
#!/bin/sh
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec /usr/lib/psysonic/psysonic "\$@"
|
||||
EOF
|
||||
|
||||
# Desktop entry
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/psysonic.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=Psysonic
|
||||
Comment=Desktop music player for Subsonic API-compatible servers
|
||||
Exec=psysonic
|
||||
Icon=psysonic
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
EOF
|
||||
|
||||
# Icons
|
||||
install -Dm644 "src-tauri/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/psysonic.png"
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 82 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.3.0"
|
||||
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.3.0"
|
||||
version = "1.5.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"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
"shell:allow-open",
|
||||
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
@@ -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","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","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: 21 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 998 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 985 KiB After Width: | Height: | Size: 829 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 47 KiB |
@@ -1,12 +1,138 @@
|
||||
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) ────────────────────────
|
||||
@@ -19,6 +145,8 @@ 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 struct AudioCurrent {
|
||||
@@ -85,6 +213,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
.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)),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
@@ -184,7 +314,12 @@ pub async fn audio_play(
|
||||
// ── 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);
|
||||
let eq_source = EqSource::new(
|
||||
decoder.convert_samples::<f32>(),
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
);
|
||||
sink.append(eq_source);
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
@@ -320,3 +455,11 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
sink.set_volume(volume.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 @@ pub fn run() {
|
||||
audio::audio_stop,
|
||||
audio::audio_seek,
|
||||
audio::audio_set_volume,
|
||||
audio::audio_set_eq,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.5",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -26,11 +26,16 @@ 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 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 +52,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 +83,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 +137,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 +147,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 +169,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>
|
||||
|
||||
@@ -163,12 +163,21 @@ export async function getAlbumList(
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size };
|
||||
const params: Record<string, string | number> = { size, _t: Date.now() };
|
||||
if (genre) params.genre = genre;
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||
return data.randomSongs?.song ?? [];
|
||||
}
|
||||
|
||||
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,211 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (
|
||||
name.startsWith('on') ||
|
||||
(name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) ||
|
||||
(name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
|
||||
<h3 className="modal-title">{t('albumDetail.bioModal')}</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlbumInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
coverArt?: string;
|
||||
recordLabel?: string;
|
||||
}
|
||||
|
||||
interface AlbumHeaderProps {
|
||||
info: AlbumInfo;
|
||||
songs: SubsonicSong[];
|
||||
coverUrl: string;
|
||||
coverKey: string;
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
bio: string | null;
|
||||
bioOpen: boolean;
|
||||
onToggleStar: () => void;
|
||||
onDownload: () => void;
|
||||
onPlayAll: () => void;
|
||||
onEnqueueAll: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
info,
|
||||
songs,
|
||||
coverUrl,
|
||||
coverKey,
|
||||
resolvedCoverUrl,
|
||||
isStarred,
|
||||
downloadProgress,
|
||||
bio,
|
||||
bioOpen,
|
||||
onToggleStar,
|
||||
onDownload,
|
||||
onPlayAll,
|
||||
onEnqueueAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span className="album-info-dot">·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
<span className="download-hint">
|
||||
<Info size={12} />
|
||||
{t('albumDetail.downloadHintShort')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { Play, Star } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlbumTrackListProps {
|
||||
songs: SubsonicSong[];
|
||||
hasVariousArtists: boolean;
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
hoveredSongId: string | null;
|
||||
setHoveredSongId: (id: string | null) => void;
|
||||
ratings: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
|
||||
}
|
||||
|
||||
export default function AlbumTrackList({
|
||||
songs,
|
||||
hasVariousArtists,
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
hoveredSongId,
|
||||
setHoveredSongId,
|
||||
ratings,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
const makeTrack = (song: SubsonicSong): Track => ({
|
||||
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,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="tracklist">
|
||||
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackRating')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
</div>
|
||||
|
||||
{discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
<div className="disc-header">
|
||||
<span className="disc-icon">💿</span>
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => onPlaySong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="track-num"
|
||||
style={{
|
||||
cursor: hoveredSongId === song.id ? 'pointer' : 'default',
|
||||
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => onPlaySong(song)}
|
||||
>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
</div>
|
||||
{hasVariousArtists && (
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -12,16 +13,14 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
>
|
||||
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
@@ -31,14 +30,14 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
<Users size={32} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
<div className="artist-card-name" data-tooltip={artist.name}>
|
||||
{artist.name}
|
||||
<div className="artist-card-info">
|
||||
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<div className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,10 +59,10 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
|
||||
if (artists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="artist-row-section">
|
||||
<div className="artist-row-header">
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="artist-row-nav">
|
||||
<div className="album-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
|
||||
@@ -10,7 +10,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
getCachedUrl(fetchUrl, cacheKey).then(setResolved);
|
||||
let cancelled = false;
|
||||
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return resolved || fetchUrl;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -146,6 +146,11 @@ export default function ContextMenu() {
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
@@ -205,6 +210,14 @@ export default function ContextMenu() {
|
||||
{t('contextMenu.removeFromQueue')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
||||
<Star size={14} /> {t('contextMenu.favorite')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
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';
|
||||
|
||||
// ─── 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) {
|
||||
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 = '#0d0d12';
|
||||
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 = 'rgba(255,255,255,0.3)';
|
||||
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();
|
||||
}
|
||||
|
||||
// ─── 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 redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const accent = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
|
||||
drawCurve(canvas, gains, accent);
|
||||
}, [gains]);
|
||||
|
||||
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" />
|
||||
<input
|
||||
type="range"
|
||||
className="eq-fader"
|
||||
min={-12} max={12} step={0.5}
|
||||
value={gains[i]}
|
||||
onChange={e => setBandGain(i, parseFloat(e.target.value))}
|
||||
disabled={!enabled}
|
||||
aria-label={`${band.label} Hz`}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -128,6 +128,7 @@ export default function QueuePanel() {
|
||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
@@ -254,7 +255,23 @@ export default function QueuePanel() {
|
||||
}}
|
||||
>
|
||||
<div className="queue-header">
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
{queue.length > 0 && (() => {
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const h = Math.floor(totalSecs / 3600);
|
||||
const m = Math.floor((totalSecs % 3600) / 60);
|
||||
const s = totalSecs % 60;
|
||||
const dur = h > 0
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
return (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
@@ -286,20 +303,22 @@ export default function QueuePanel() {
|
||||
)}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3
|
||||
className="truncate"
|
||||
data-tooltip={currentTrack.title}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.title}</h3>
|
||||
{currentTrack.year && <div className="queue-current-sub">{currentTrack.year}</div>}
|
||||
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
data-tooltip={currentTrack.artist}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>{currentTrack.artist}</div>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
data-tooltip={currentTrack.album}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.album}</div>
|
||||
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix ? (
|
||||
@@ -343,7 +362,7 @@ export default function QueuePanel() {
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''}`}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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,7 @@ const enTranslation = {
|
||||
favorite: 'Favorite',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Albums',
|
||||
openedInBrowser: 'Opened in browser',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
@@ -127,6 +130,7 @@ const enTranslation = {
|
||||
artists: 'Artists',
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
enqueueAll: 'Add all to queue',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
@@ -169,6 +173,11 @@ const enTranslation = {
|
||||
minutes: 'min.',
|
||||
track_one: '{{count}} Track',
|
||||
track_other: '{{count}} Tracks',
|
||||
filterPlaceholder: 'Filter playlists…',
|
||||
colName: 'Name',
|
||||
colTracks: 'Tracks',
|
||||
colDuration: 'Duration',
|
||||
noResults: 'No playlists match your filter.',
|
||||
},
|
||||
albums: {
|
||||
title: 'All Albums',
|
||||
@@ -207,6 +216,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',
|
||||
@@ -250,6 +268,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',
|
||||
@@ -271,7 +299,7 @@ const enTranslation = {
|
||||
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',
|
||||
@@ -367,14 +395,28 @@ const enTranslation = {
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
emptyQueue: 'The queue is empty.'
|
||||
emptyQueue: 'The queue is empty.',
|
||||
trackSingular: 'track',
|
||||
trackPlural: 'tracks',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistics',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
mostPlayed: 'Most Played Albums',
|
||||
highestRated: 'Highest Rated Albums',
|
||||
genreDistribution: 'Genre Distribution (Top 20)',
|
||||
loadMore: 'Load more',
|
||||
statArtists: 'Artists',
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Albums',
|
||||
recentlyAdded: 'Recently Added',
|
||||
decadeDistribution: 'Albums by Decade',
|
||||
decadeAlbums_one: '{{count}} Album',
|
||||
decadeAlbums_other: '{{count}} Albums',
|
||||
decadeUnknown: 'Unknown',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Music Player',
|
||||
@@ -412,6 +454,7 @@ const deTranslation = {
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Zufallsmix',
|
||||
favorites: 'Favoriten',
|
||||
nowPlaying: 'Now Playing',
|
||||
system: 'System',
|
||||
statistics: 'Statistiken',
|
||||
settings: 'Einstellungen',
|
||||
@@ -453,6 +496,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',
|
||||
@@ -519,6 +563,7 @@ const deTranslation = {
|
||||
favorite: 'Als Favorit',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Alben',
|
||||
openedInBrowser: 'Im Browser geöffnet',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
@@ -526,6 +571,7 @@ const deTranslation = {
|
||||
artists: 'Künstler',
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
enqueueAll: 'Alle in die Warteschlange',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
@@ -568,6 +614,11 @@ const deTranslation = {
|
||||
minutes: 'Min.',
|
||||
track_one: '{{count}} Track',
|
||||
track_other: '{{count}} Tracks',
|
||||
filterPlaceholder: 'Playlists filtern…',
|
||||
colName: 'Name',
|
||||
colTracks: 'Tracks',
|
||||
colDuration: 'Dauer',
|
||||
noResults: 'Keine Playlists entsprechen dem Filter.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Alle Alben',
|
||||
@@ -606,6 +657,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',
|
||||
@@ -649,6 +709,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',
|
||||
@@ -670,7 +740,7 @@ const deTranslation = {
|
||||
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',
|
||||
@@ -762,18 +832,32 @@ const deTranslation = {
|
||||
delete: 'Löschen',
|
||||
deleteConfirm: 'Playlist "{{name}}" löschen?',
|
||||
clear: 'Leeren',
|
||||
shuffle: 'Queue mischen',
|
||||
shuffle: 'Warteschlange mischen',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
emptyQueue: 'Die Warteschlange ist leer.'
|
||||
emptyQueue: 'Die Warteschlange ist leer.',
|
||||
trackSingular: 'Titel',
|
||||
trackPlural: 'Titel',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiken',
|
||||
recentlyPlayed: 'Zuletzt gehört',
|
||||
mostPlayed: 'Meistgespielte Alben',
|
||||
highestRated: 'Höchstbewertete Alben',
|
||||
genreDistribution: 'Genre-Verteilung (Top 20)',
|
||||
loadMore: 'Mehr laden',
|
||||
statArtists: 'Künstler',
|
||||
statAlbums: 'Alben',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Alben',
|
||||
recentlyAdded: 'Neu hinzugefügt',
|
||||
decadeDistribution: 'Alben nach Jahrzehnt',
|
||||
decadeAlbums_one: '{{count}} Album',
|
||||
decadeAlbums_other: '{{count}} Alben',
|
||||
decadeUnknown: 'Unbekannt',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikplayer',
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import AlbumHeader from '../components/AlbumHeader';
|
||||
import AlbumTrackList from '../components/AlbumTrackList';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
@@ -20,78 +19,6 @@ function sanitizeFilename(name: string): string {
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Strip dangerous tags/attributes from server-provided HTML (e.g. artist bios from Last.fm) */
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1,2,3,4,5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BioModalProps { bio: string; onClose: () => void; }
|
||||
function BioModal({ bio, onClose }: BioModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('albumDetail.bioModal')}</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -102,6 +29,7 @@ export default function AlbumDetail() {
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
|
||||
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
@@ -137,8 +65,8 @@ export default function AlbumDetail() {
|
||||
if (!album) return;
|
||||
const tracks = album.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,
|
||||
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[0]) playTrack(tracks[0], tracks);
|
||||
};
|
||||
@@ -147,8 +75,8 @@ export default function AlbumDetail() {
|
||||
if (!album) return;
|
||||
const tracks = album.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,
|
||||
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);
|
||||
};
|
||||
@@ -157,8 +85,7 @@ export default function AlbumDetail() {
|
||||
const track = {
|
||||
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
|
||||
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
@@ -176,7 +103,9 @@ export default function AlbumDetail() {
|
||||
setBioOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async (albumName: string, albumId: string) => {
|
||||
const handleDownload = async () => {
|
||||
if (!album) return;
|
||||
const { name, id: albumId } = album.album;
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
@@ -206,13 +135,13 @@ export default function AlbumDetail() {
|
||||
const blob = new Blob(chunks);
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
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(albumName)}.zip`;
|
||||
a.download = `${sanitizeFilename(name)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@@ -222,33 +151,31 @@ export default function AlbumDetail() {
|
||||
console.error('Download failed:', e);
|
||||
setDownloadProgress(null);
|
||||
} finally {
|
||||
// keep bar visible at 100% for 3 seconds so user sees completion
|
||||
setTimeout(() => setDownloadProgress(null), 60000);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!album) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred);
|
||||
const wasStarred = isStarred;
|
||||
setIsStarred(!wasStarred);
|
||||
try {
|
||||
if (currentlyStarred) await unstar(album.album.id);
|
||||
if (wasStarred) await unstar(album.album.id);
|
||||
else await star(album.album.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred);
|
||||
setIsStarred(wasStarred);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
const wasStarred = starredSongs.has(song.id);
|
||||
const next = new Set(starredSongs);
|
||||
if (wasStarred) next.delete(song.id); else next.add(song.id);
|
||||
setStarredSongs(next);
|
||||
try {
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
if (wasStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
@@ -265,234 +192,47 @@ export default function AlbumDetail() {
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
const { album: info, songs } = album;
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="album-detail animate-fade-in">
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
|
||||
<AlbumHeader
|
||||
info={info}
|
||||
songs={songs}
|
||||
coverUrl={coverUrl}
|
||||
coverKey={coverKey}
|
||||
resolvedCoverUrl={resolvedCoverUrl}
|
||||
isStarred={isStarred}
|
||||
downloadProgress={downloadProgress}
|
||||
bio={bio}
|
||||
bioOpen={bioOpen}
|
||||
onToggleStar={toggleStar}
|
||||
onDownload={handleDownload}
|
||||
onPlayAll={handlePlayAll}
|
||||
onEnqueueAll={handleEnqueueAll}
|
||||
onBio={handleBio}
|
||||
onCloseBio={() => setBioOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ marginBottom: '1rem', gap: '6px' }}>
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge" style={{ marginBottom: '0.5rem' }}>{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={handlePlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
<span className="download-hint">
|
||||
<Info size={12} />
|
||||
{t('albumDetail.downloadHintShort')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tracklist">
|
||||
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackRating')}</div>
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
return discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
<div className="disc-header">
|
||||
<span className="disc-icon">💿</span>
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => handlePlaySong(song)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const track = {
|
||||
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,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, track, 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
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,
|
||||
};
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar"/><span className="eq-bar"/><span className="eq-bar"/></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
</div>
|
||||
{hasVariousArtists && (
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => handleRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration" style={{ textAlign: 'center' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec" style={{ marginTop: 0 }}>
|
||||
{codecLabel(song)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
|
||||
{/* Total row */}
|
||||
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AlbumTrackList
|
||||
songs={songs}
|
||||
hasVariousArtists={hasVariousArtists}
|
||||
currentTrack={currentTrack}
|
||||
isPlaying={isPlaying}
|
||||
hoveredSongId={hoveredSongId}
|
||||
setHoveredSongId={setHoveredSongId}
|
||||
ratings={ratings}
|
||||
starredSongs={starredSongs}
|
||||
onPlaySong={handlePlaySong}
|
||||
onRate={handleRate}
|
||||
onToggleSongStar={toggleSongStar}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
|
||||
<div style={{ borderTop: '1px solid var(--border-subtle)', marginBottom: '2rem' }} />
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||
<div className="album-related">
|
||||
<div className="album-related-divider" />
|
||||
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function ArtistDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
@@ -77,15 +78,10 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const openLink = (url: string, title: string) => {
|
||||
const label = `browser_${Date.now()}`;
|
||||
new WebviewWindow(label, {
|
||||
url,
|
||||
title,
|
||||
width: 1100,
|
||||
height: 780,
|
||||
center: true,
|
||||
});
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
setOpenedLink(key);
|
||||
setTimeout(() => setOpenedLink(null), 2500);
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
@@ -192,14 +188,14 @@ export default function ArtistDetail() {
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, `${artist.name} — Last.fm`)}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, 'lastfm')}>
|
||||
<LastfmIcon size={14} />
|
||||
Last.fm
|
||||
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, `${artist.name} — Wikipedia`)}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
||||
<ExternalLink size={14} />
|
||||
Wikipedia
|
||||
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,8 @@ import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play } from 'lucide-react';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Favorites() {
|
||||
@@ -13,7 +14,9 @@ export default function Favorites() {
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack } = usePlayerStore();
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getStarred()
|
||||
@@ -56,44 +59,68 @@ export default function Favorites() {
|
||||
|
||||
{songs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('favorites.songs')}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => {
|
||||
const tracks = 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);
|
||||
}}
|
||||
>
|
||||
<ListPlus size={15} />
|
||||
{t('favorites.enqueueAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
{songs.map((song) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px 1fr 60px' }}
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
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,
|
||||
};
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
|
||||
<div className="tracklist-header tracklist-va">
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
</div>
|
||||
{songs.map((song, i) => {
|
||||
const track = {
|
||||
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,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-info">
|
||||
<span className="track-title" title={song.title}>{song.title}</span>
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
<div className="track-num col-center" onClick={() => playTrack(song, songs)} style={{ cursor: 'pointer' }}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
className="track-artist"
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function NewReleases() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -49,7 +51,7 @@ export default function NewReleases() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>Neueste</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
|
||||
@@ -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,13 +1,43 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { ListMusic, Play, Trash2 } from 'lucide-react';
|
||||
import { Play, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SortKey = 'name' | 'songCount' | 'duration';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function SortHeader({
|
||||
label, sortKey, current, dir, onSort
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: SortKey;
|
||||
current: SortKey;
|
||||
dir: SortDir;
|
||||
onSort: (k: SortKey) => void;
|
||||
}) {
|
||||
const active = current === sortKey;
|
||||
return (
|
||||
<button className={`playlist-sort-btn${active ? ' active' : ''}`} onClick={() => onSort(sortKey)}>
|
||||
{label}
|
||||
{active ? (dir === 'asc' ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
@@ -15,104 +45,93 @@ export default function Playlists() {
|
||||
const fetchPlaylists = () => {
|
||||
setLoading(true);
|
||||
getPlaylists()
|
||||
.then(data => {
|
||||
setPlaylists(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load playlists', err);
|
||||
setLoading(false);
|
||||
});
|
||||
.then(data => { setPlaylists(data); setLoading(false); })
|
||||
.catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists();
|
||||
}, []);
|
||||
useEffect(() => { fetchPlaylists(); }, []);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortKey(key); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
const handlePlay = async (id: string) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const tracks = data.songs.map((s: any) => ({
|
||||
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,
|
||||
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);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to play playlist', e);
|
||||
}
|
||||
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
|
||||
} catch (e) { console.error('Failed to play playlist', e); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(t('playlists.confirmDelete', { name }))) {
|
||||
try {
|
||||
await deletePlaylist(id);
|
||||
fetchPlaylists();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete playlist', e);
|
||||
}
|
||||
try { await deletePlaylist(id); fetchPlaylists(); }
|
||||
catch (e) { console.error('Failed to delete playlist', e); }
|
||||
}
|
||||
};
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = filter.toLowerCase();
|
||||
const filtered = q ? playlists.filter(p => p.name.toLowerCase().includes(q)) : playlists;
|
||||
return [...filtered].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
|
||||
else if (sortKey === 'songCount') cmp = a.songCount - b.songCount;
|
||||
else cmp = a.duration - b.duration;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [playlists, filter, sortKey, sortDir]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<ListMusic size={32} style={{ color: 'var(--accent)' }} />
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{t('playlists.title')}</h1>
|
||||
<div className="playlist-page-header">
|
||||
<h1 className="page-title">{t('playlists.title')}</h1>
|
||||
<input
|
||||
className="playlist-filter-input"
|
||||
type="search"
|
||||
placeholder={t('playlists.filterPlaceholder')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">{t('playlists.loading')}</div>
|
||||
<div className="loading-center"><div className="spinner" /></div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>
|
||||
{t('playlists.empty')}
|
||||
</div>
|
||||
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>{t('playlists.empty')}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
|
||||
{playlists.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
background: 'var(--surface0)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
border: '1px solid var(--surface1)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
className="hover-card"
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 600, margin: '0 0 0.25rem 0', color: 'var(--text)' }} className="truncate">
|
||||
{p.name}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--subtext0)' }}>
|
||||
{t('playlists.track', { count: p.songCount })} • {Math.floor(p.duration / 60)} {t('playlists.minutes')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="playlist-list">
|
||||
<div className="playlist-list-header">
|
||||
<div />
|
||||
<SortHeader label={t('playlists.colName')} sortKey="name" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label={t('playlists.colTracks')} sortKey="songCount" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label={t('playlists.colDuration')} sortKey="duration" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handlePlay(p.id)}
|
||||
style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<Play size={16} fill="currentColor" /> {t('playlists.play')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
data-tooltip={t('playlists.deleteTooltip')}
|
||||
style={{ width: '42px', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--red)' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
<div className="empty-state">{t('playlists.noResults')}</div>
|
||||
) : visible.map(p => (
|
||||
<div key={p.id} className="playlist-row">
|
||||
<button className="playlist-play-icon" onClick={() => handlePlay(p.id)} data-tooltip={t('playlists.play')}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<span className="playlist-name truncate">{p.name}</span>
|
||||
<span className="playlist-meta">{t('playlists.track', { count: p.songCount })}</span>
|
||||
<span className="playlist-meta">{formatDuration(p.duration)}</span>
|
||||
<button
|
||||
className="btn btn-ghost playlist-delete-btn"
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
data-tooltip={t('playlists.deleteTooltip')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,61 +4,30 @@ import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const INTERVAL_MS = 30000;
|
||||
const ALBUM_COUNT = 30;
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [renderKey, setRenderKey] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumList('random', ALBUM_COUNT);
|
||||
setAlbums(data);
|
||||
setRenderKey(k => k + 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCycle = useCallback(() => {
|
||||
// Clear existing timers
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (progressRef.current) clearInterval(progressRef.current);
|
||||
|
||||
// Reset progress bar
|
||||
setProgress(0);
|
||||
const startTime = Date.now();
|
||||
progressRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
setProgress(Math.min((elapsed / INTERVAL_MS) * 100, 100));
|
||||
}, 100);
|
||||
|
||||
// Auto-refresh
|
||||
timerRef.current = setInterval(() => {
|
||||
load().then(() => startCycle());
|
||||
}, INTERVAL_MS);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
load().then(() => startCycle());
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (progressRef.current) clearInterval(progressRef.current);
|
||||
};
|
||||
}, [load, startCycle]);
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
load().then(() => startCycle());
|
||||
};
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
@@ -66,7 +35,7 @@ export default function RandomAlbums() {
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleManualRefresh}
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
@@ -75,17 +44,12 @@ export default function RandomAlbums() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Countdown progress bar */}
|
||||
<div className="random-albums-progress">
|
||||
<div className="random-albums-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap animate-fade-in" key={renderKey}>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -42,6 +42,9 @@ export default function RandomMix() {
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||
@@ -55,9 +58,11 @@ export default function RandomMix() {
|
||||
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
|
||||
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
|
||||
const [genreMixLoading, setGenreMixLoading] = useState(false);
|
||||
const [genreMixComplete, setGenreMixComplete] = useState(false);
|
||||
|
||||
const fetchSongs = () => {
|
||||
setLoading(true);
|
||||
setSongs([]);
|
||||
getRandomSongs(50)
|
||||
.then(fetched => {
|
||||
setSongs(fetched);
|
||||
@@ -69,6 +74,10 @@ export default function RandomMix() {
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSongs();
|
||||
getGenres().then(setServerGenres).catch(() => {});
|
||||
@@ -89,7 +98,11 @@ export default function RandomMix() {
|
||||
});
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (filteredSongs.length > 0) playTrack(filteredSongs[0], filteredSongs);
|
||||
if (selectedSuperGenre && genreMixSongs.length > 0) {
|
||||
playTrack(genreMixSongs[0], genreMixSongs);
|
||||
} else if (filteredSongs.length > 0) {
|
||||
playTrack(filteredSongs[0], filteredSongs);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
@@ -119,10 +132,13 @@ export default function RandomMix() {
|
||||
const loadGenreMix = async (superGenreId: string) => {
|
||||
const sg = SUPER_GENRES.find(s => s.id === superGenreId);
|
||||
if (!sg) return;
|
||||
const matched = serverGenres
|
||||
const allMatched = serverGenres
|
||||
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
|
||||
.map(sg2 => sg2.value);
|
||||
.map(sg2 => sg2.value)
|
||||
.sort(() => Math.random() - 0.5);
|
||||
const matched = allMatched.slice(0, 50);
|
||||
setGenreMixLoading(true);
|
||||
setGenreMixComplete(false);
|
||||
setGenreMixSongs([]);
|
||||
|
||||
const perGenre = Math.max(1, Math.ceil(50 / matched.length));
|
||||
@@ -149,6 +165,7 @@ export default function RandomMix() {
|
||||
return s.slice(0, 50);
|
||||
});
|
||||
setGenreMixLoading(false);
|
||||
setGenreMixComplete(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -161,9 +178,23 @@ export default function RandomMix() {
|
||||
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
|
||||
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || filteredSongs.length === 0}>
|
||||
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
|
||||
</button>
|
||||
{(() => {
|
||||
const isGenreLoading = selectedSuperGenre && !genreMixComplete;
|
||||
const isDisabled = loading || (selectedSuperGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0);
|
||||
return (
|
||||
<button
|
||||
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
|
||||
onClick={handlePlayAll}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isGenreLoading ? (
|
||||
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongs.length, 50)} / 50</>
|
||||
) : (
|
||||
<><Play size={18} fill="currentColor" /> {t('randomMix.playAll')}</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,11 +326,6 @@ export default function RandomMix() {
|
||||
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix
|
||||
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }} onClick={() => genreMixSongs.length > 0 && playTrack(genreMixSongs[0], genreMixSongs)} disabled={genreMixLoading || genreMixSongs.length === 0}>
|
||||
<Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{genreMixLoading && genreMixSongs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||
@@ -314,8 +340,9 @@ export default function RandomMix() {
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
</div>
|
||||
{genreMixSongs.map(song => (
|
||||
<div key={song.id} className="track-row" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
|
||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { 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 }, 'song'); }}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { 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 } }));
|
||||
@@ -357,11 +384,17 @@ export default function RandomMix() {
|
||||
{filteredSongs.map((song) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, filteredSongs)}
|
||||
role="row"
|
||||
draggable
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
const track = { 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 };
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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'];
|
||||
|
||||
@@ -276,6 +277,17 @@ export default function Settings() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Last.fm */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
@@ -460,7 +472,7 @@ export default function Settings() {
|
||||
</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 }} />
|
||||
<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
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import { getAlbumList, getArtists, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Statistics() {
|
||||
const { t } = useTranslation();
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
||||
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [artistCount, setArtistCount] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('recent', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('highest', 12).catch(() => []),
|
||||
getGenres().catch(() => [])
|
||||
]).then(([f, h, g]) => {
|
||||
setFrequent(f);
|
||||
setHighest(h);
|
||||
// Sort genres by album count or song count
|
||||
setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20)); // Top 20 genres
|
||||
getGenres().catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
]).then(([rc, fr, hi, g, a]) => {
|
||||
setRecent(rc);
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20));
|
||||
setArtistCount(a.length);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -33,29 +37,44 @@ export default function Statistics() {
|
||||
try {
|
||||
const more = await getAlbumList(type, 12, currentList.length);
|
||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||
if (newItems.length > 0) {
|
||||
setter(prev => [...prev, ...newItems]);
|
||||
}
|
||||
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||
} catch (e) {
|
||||
console.error('Failed to load more', e);
|
||||
}
|
||||
};
|
||||
|
||||
const totalSongs = genres.reduce((acc, g) => acc + g.songCount, 0);
|
||||
const totalAlbums = genres.reduce((acc, g) => acc + g.albumCount, 0);
|
||||
const maxGenreCount = Math.max(...genres.map(g => g.songCount), 1);
|
||||
|
||||
const stats = [
|
||||
{ label: t('statistics.statArtists'), value: artistCount },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums || null },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs || null },
|
||||
{ label: t('statistics.statGenres'), value: genres.length || null },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<BarChart3 size={32} style={{ color: 'var(--accent)' }} />
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{t('statistics.title')}</h1>
|
||||
</div>
|
||||
<h1 className="page-title">{t('statistics.title')}</h1>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
<div className="loading-center"><div className="spinner" /></div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
<div className="stats-page">
|
||||
|
||||
<div className="stats-overview">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className="stats-card">
|
||||
<span className="stats-card-value">{s.value?.toLocaleString() ?? '—'}</span>
|
||||
<span className="stats-card-label">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
|
||||
)}
|
||||
|
||||
<AlbumRow
|
||||
title={t('statistics.mostPlayed')}
|
||||
@@ -72,36 +91,29 @@ export default function Statistics() {
|
||||
/>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<div>
|
||||
<div className="section-title">
|
||||
<h2>{t('statistics.genreDistribution')}</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '1rem', background: 'var(--surface0)', padding: '1.5rem', borderRadius: '12px' }}>
|
||||
{genres.map(genre => {
|
||||
const percentage = (genre.songCount / maxGenreCount) * 100;
|
||||
return (
|
||||
<div key={genre.value} style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem', color: 'var(--text)' }}>
|
||||
<span>{genre.value}</span>
|
||||
<span style={{ color: 'var(--subtext0)' }}>{genre.songCount} Songs</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '8px', background: 'var(--surface2)', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 1s ease-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<section>
|
||||
<h2 className="section-title">{t('statistics.genreDistribution')}</h2>
|
||||
<div className="genre-chart">
|
||||
{genres.map(genre => (
|
||||
<div key={genre.value} className="genre-row">
|
||||
<div className="genre-row-header">
|
||||
<span className="genre-name">{genre.value}</span>
|
||||
<span className="genre-counts">
|
||||
{t('statistics.genreSongs', { count: genre.songCount })}
|
||||
{' · '}
|
||||
{t('statistics.genreAlbums', { count: genre.albumCount })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="genre-bar-track">
|
||||
<div
|
||||
className="genre-bar-fill"
|
||||
style={{ width: `${(genre.songCount / maxGenreCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||