Compare commits
1 Commits
app-v1.6.0
..
v1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 492f069bf1 |
@@ -14,61 +14,31 @@ jobs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
package_version: ${{ steps.get-version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- 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;
|
||||
}
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
tag_name: `app-v${process.env.PACKAGE_VERSION}`,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body,
|
||||
body: 'See the assets to download this version and install.',
|
||||
draft: false,
|
||||
prerelease: false
|
||||
});
|
||||
return data.id;
|
||||
})
|
||||
return data.id
|
||||
|
||||
build-macos-windows:
|
||||
needs: create-release
|
||||
@@ -86,9 +56,9 @@ jobs:
|
||||
args: ''
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: install Rust stable
|
||||
@@ -108,19 +78,26 @@ jobs:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
@@ -131,7 +108,21 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: build
|
||||
run: npm run tauri:build -- --bundles deb,rpm
|
||||
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
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
@@ -139,5 +130,5 @@ jobs:
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
\( -name "*.AppImage" -not -name "*.tar.gz" -o -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
@@ -5,405 +5,6 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.6.0] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
#### Replay Gain
|
||||
- **Replay Gain support** in the Rust audio engine. Gain and peak values from the Subsonic API are applied per-track at playback time, keeping loudness consistent across your library.
|
||||
- Two modes selectable in Settings → Playback: **Track** (default) and **Album** gain.
|
||||
- Peak limiting applied to prevent clipping: effective gain is capped at `1 / peak`.
|
||||
- Volume slider preserves the gain ratio — `audio_set_volume` multiplies `base_volume × replay_gain_linear`.
|
||||
|
||||
#### Crossfade
|
||||
- **Crossfade between tracks** (0.5 – 12 s, configurable in Settings → Playback).
|
||||
- Old sink is volume-ramped to zero in 30 steps while the new track starts playing; old sink stored in `fading_out_sink` so a subsequent skip cancels the fade-out immediately.
|
||||
- `audio_set_crossfade` Tauri command; synced to Rust on startup and on toggle.
|
||||
|
||||
#### Gapless Preloading *(Experimental — Alpha)*
|
||||
- **Gapless playback**: when ≤ 30 s remain in the current track, the next track's audio is preloaded via `audio_preload` in the background.
|
||||
- `audio_play` checks the preload cache first — if there is a URL match the download is skipped entirely, eliminating the gap between tracks.
|
||||
- The old Sink is kept alive during the new track's download and decode phase; the Sink swap happens atomically after decoding is complete, fixing a subtle **start-of-track audio cut** that occurred regardless of gapless state.
|
||||
- ⚠️ **This feature is experimental and still in active development.** It may not work correctly in all scenarios. Enable it in Settings → Playback at your own discretion.
|
||||
|
||||
#### Settings — Tab Navigation
|
||||
- Settings reorganised into **5 horizontal tabs**: Playback, Library, Appearance, Server, About.
|
||||
- Each tab groups related settings with a matching icon.
|
||||
|
||||
#### Artist Pages — "Also Featured On"
|
||||
- Artist detail pages now show an **"Also Featured On"** section listing albums where the artist appears as a guest or featured performer (but is not the primary album artist).
|
||||
- Implemented via `search3` filtered by `song.artistId`, excluding the artist's own albums.
|
||||
|
||||
#### Download Folder Modal
|
||||
- When no download folder is configured and the user initiates a download (album or track), a **folder picker modal** now appears asking where to save.
|
||||
- Includes a "Remember this folder" checkbox that writes the choice to Settings.
|
||||
- Clear button added in Settings → Server to reset the saved download folder.
|
||||
|
||||
#### Changelog in Settings
|
||||
- The full **Changelog** is now readable inside the app under Settings → About.
|
||||
- Rendered as collapsible version entries; the current version is expanded by default.
|
||||
- Inline Markdown (`**bold**`, `*italic*`, `` `code` ``) is rendered natively.
|
||||
|
||||
#### EQ as Player Bar Popup
|
||||
- The Equalizer is now accessible directly from the **player bar** via a small EQ button, opening as a centred popup overlay — no need to navigate to Settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bundle identifier warning**: changed `identifier` from `dev.psysonic.app` to `dev.psysonic.player` to avoid the macOS `.app` extension conflict warned by Tauri.
|
||||
- **Version mismatch in releases**: `tauri.conf.json` version was out of sync with `package.json` and `Cargo.toml`, causing GitHub Actions to build release artefacts with the wrong version number. All four version sources (`package.json`, `Cargo.toml`, `tauri.conf.json`, `packages/aur/PKGBUILD`) are now kept in sync.
|
||||
|
||||
### Known Issues
|
||||
|
||||
- **FLAC seeking**: jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
#### 10-Band Graphic Equalizer
|
||||
- Full **10-band graphic EQ** implemented entirely in the Rust audio engine using biquad peak filters (31 Hz – 16 kHz). Gains adjustable ±12 dB per band.
|
||||
- EQ is processed in the audio pipeline via `EqSource<S>` — a custom `rodio::Source` wrapper that applies cascaded biquad filters in real-time.
|
||||
- Filter coefficients update smoothly on every 1024-sample block without audio interruption.
|
||||
- **Seek support**: `EqSource::try_seek()` implemented — filter state is reset on seek to prevent clicks/artefacts. This also **fixes waveform seek**, which had silently broken when the EQ was introduced (rodio returned `SeekError::NotSupported` without the impl).
|
||||
- **10 built-in presets**: Flat, Bass Boost, Treble Boost, Rock, Pop, Jazz, Classical, Electronic, Vocal, Acoustic.
|
||||
- Custom presets: save, name, and delete your own presets.
|
||||
- EQ state persisted via `psysonic-eq` localStorage key (gains, enabled, active preset, custom presets).
|
||||
- New `audio_set_eq` Tauri command; settings synced to Rust on startup via `eqStore.syncToRust()`.
|
||||
|
||||
#### Connection Indicator
|
||||
- **LED indicator** in the header bar (green = connected, red = disconnected, pulsing = checking). Sits between the search bar and the Now Playing dropdown.
|
||||
- Shows server name and LAN/WAN status next to the LED.
|
||||
- **Offline overlay**: when the server is unreachable, a full-content-area overlay appears with a retry button.
|
||||
- `useConnectionStatus` hook pings the active server periodically and exposes `status`, `isRetrying`, `retry`, `isLan`, and `serverName`.
|
||||
|
||||
#### Now Playing Page
|
||||
- New `/now-playing` route and `NowPlayingPage` component — accessible from the sidebar.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Waveform Seek (Player Bar)
|
||||
- **Drag out of canvas no longer breaks seeking**: `mousemove` and `mouseup` events are now registered on `window` (not the canvas element), so dragging fast across other elements still updates playback position correctly.
|
||||
- **Stale closure fix**: `trackId` and `seek` function are kept in refs so the window-level handlers always see the current values.
|
||||
|
||||
### Changed
|
||||
|
||||
#### App Icon
|
||||
- New app icon (`public/logo-psysonic.png`) across all platforms — Login page, Sidebar, Settings About section, README header, and all generated Tauri platform icons (Windows ICO, macOS ICNS, Linux PNGs, Android, iOS).
|
||||
|
||||
## [1.4.5] - 2026-03-17
|
||||
|
||||
### Changed
|
||||
|
||||
#### Artist Pages — External Links
|
||||
- Last.fm and Wikipedia buttons now open in the **system browser** instead of an in-app window. The button label temporarily changes to "Opened in browser" / "Im Browser geöffnet" for 2.5 seconds as visual confirmation.
|
||||
|
||||
#### Queue Panel
|
||||
- **Release year** added to the now-playing meta box, shown below the album name (when available).
|
||||
- **Cover art enlarged** from 72 × 72 px to 90 × 90 px, aligned to the top of the meta block so it lines up with the song title.
|
||||
- **Default width increased** from 300 px to 340 px.
|
||||
|
||||
## [1.4.4] - 2026-03-17
|
||||
|
||||
### Added
|
||||
|
||||
#### 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
|
||||
|
||||
#### Player Bar — Complete Redesign
|
||||
- **Waveform seekbar**: Replaces the classic thin slider. A canvas-based waveform with 500 deterministic bars (seeded by `trackId`) fills the full available width. Played portion renders as a blue → mauve gradient with a soft glow; buffered range is slightly brighter; unplayed bars are dimmed to 28% opacity. Click or drag anywhere to seek.
|
||||
- **New layout**: Single flex row — `[Cover + Track Info] [Transport Controls] [Waveform + Times] [Volume]`. More breathing room for the waveform; controls feel lighter and better proportioned.
|
||||
- **Queue toggle relocated**: Moved from the bottom player bar to the top-right of the content header — consistent with the sidebar collapse button pattern. Uses `PanelRightClose` / `PanelRight` icons (same family as `PanelLeftClose` / `PanelLeft` in the sidebar).
|
||||
|
||||
#### Ambient Stage — MilkDrop Visualizer
|
||||
- **Butterchurn integration**: Clicking the waveform icon (top-right of the fullscreen player) activates the MilkDrop visualizer powered by [butterchurn](https://github.com/jberg/butterchurn) + `butterchurn-presets`.
|
||||
- A hidden `<audio>` element is routed through the Web Audio API `AnalyserNode` (not connected to `AudioDestinationNode` — completely silent). The Rust/rodio engine continues to handle actual audio output.
|
||||
- Starts with a random preset; the shuffle button cycles through all available presets with a 2-second blend transition. Current preset name is shown in the top bar.
|
||||
- When the visualizer is active, the blurred background, orbs, and overlay are replaced by the canvas.
|
||||
|
||||
#### Tracklist — Animated Equalizer Indicator
|
||||
- The currently **playing** track shows three animated equalizer bars (CSS `scaleY` keyframe animation, staggered timing) instead of a static play icon.
|
||||
- When **paused**, the static play icon is shown.
|
||||
- Hovering any other track still shows a play icon.
|
||||
- Track row alignment fixed: `align-items: center` on the grid row + `.track-num` as flex center — icons and track numbers are now perfectly vertically aligned with the song title.
|
||||
|
||||
#### Artist Pages — In-App Browser
|
||||
- Last.fm and Wikipedia buttons now open a native **Tauri `WebviewWindow`** (1100 × 780, centered) instead of the system browser. Both sites load fully within the app and can be closed independently.
|
||||
- Required new capabilities: `core:window:allow-create`, `core:webview:allow-create-webview-window`.
|
||||
|
||||
#### Update Checker
|
||||
- Update check now runs **every 10 minutes** during runtime in addition to the initial check 1.5 s after launch.
|
||||
- Version label in the update toast no longer includes a `v` prefix (shows `1.3.0` instead of `v1.3.0`).
|
||||
|
||||
#### Help Page
|
||||
- New **Random Mix** section: explains the random mix, keyword filter, and super genre mix.
|
||||
- Updated **Playback** section: waveform seekbar, MilkDrop visualizer, queue shuffle.
|
||||
- Updated **Library** section: in-app browser for artist links.
|
||||
- Updated queue entry to reflect the new toggle location.
|
||||
- **Accordion styling**: open question and answer share a continuous 3 px accent stripe on the left; answer background uses `--bg-app` for clear contrast against the question's `--bg-card`.
|
||||
|
||||
### Fixed
|
||||
- **Version in Settings** was hardcoded to `1.0.12`. Now imported from `package.json` at build time — same source as the sidebar update checker.
|
||||
- **Hero / Discover duplicate albums**: Both sections previously fetched `random` independently, often showing the same albums. Now a single request fetches 20; `slice(0, 8)` goes to the Hero carousel and `slice(8)` to the Discover row.
|
||||
- **Active track pulse too aggressive**: Changed from a `background: transparent` flash to a gentle `opacity: 0.6` fade over 3 s — significantly less distracting.
|
||||
|
||||
### Changed
|
||||
- **Blacklist → Keyword Filter**: Renamed throughout UI and i18n (EN + DE) to better reflect that the filter matches genre, title, and album fields — not just genre tags.
|
||||
|
||||
## [1.2.0] - 2026-03-15
|
||||
|
||||
### Added
|
||||
|
||||
#### Rust Audio Engine (replaces Howler.js)
|
||||
- **New native audio backend** built in Rust using [rodio](https://github.com/RustAudio/rodio). Audio is now decoded and played entirely in the Tauri backend — no more reliance on the WebView's `<audio>` element or GStreamer pipeline quirks.
|
||||
- Tauri commands: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume`.
|
||||
- Frontend events: `audio:playing` (with duration), `audio:progress` (every 500 ms), `audio:ended`, `audio:error`.
|
||||
- Generation counter (`AtomicU64`) ensures stale downloads from skipped tracks are cancelled immediately and do not emit events.
|
||||
- Wall-clock position tracking (`seek_offset + elapsed`) instead of `sink.empty()` (unreliable in rodio 0.19 for VBR MP3). `audio:ended` fires after two consecutive ticks within 1 second of the track end — avoids false positives near the end without adding latency.
|
||||
- Seek via `sink.try_seek()` — no pause/play cycle, no spurious `ended` events.
|
||||
- Volume clamped to `[0.0, 1.0]` on every call.
|
||||
|
||||
#### Playback Persistence & Cold-Start Resume
|
||||
- `currentTrack`, `queue`, `queueIndex`, and `currentTime` are now persisted to `localStorage` via Zustand `partialize`.
|
||||
- On app restart with a previously loaded track, clicking Play resumes from the saved position without losing the queue.
|
||||
- Position priority: server play queue position (if > 0) takes precedence over the locally saved value, so cross-device resume works correctly.
|
||||
|
||||
#### Random Mix — Genre Filter & Blacklist
|
||||
- **Exclude audiobooks & radio plays** toggle: filters out songs whose genre, title, or album match a hardcoded list (`Hörbuch`, `Hörspiel`, `Audiobook`, `Spoken Word`, `Podcast`, `Krimi`, `Thriller`, `Speech`, `Fantasy`, `Comedy`, `Literature`, and more).
|
||||
- **Custom genre blacklist**: add any genre keyword via the collapsible chip panel on the Random Mix page or in Settings → Random Mix. Persisted across sessions.
|
||||
- **Clickable genre chips** in the tracklist: clicking an unblocked genre tag adds it to the blacklist instantly with 1.5 s visual feedback. Blocked genres are shown in red.
|
||||
- Blacklist filter checks `song.genre`, `song.title`, and `song.album` to catch mislabelled tracks.
|
||||
|
||||
#### Random Mix — Super Genre Mix
|
||||
- Nine pre-defined **Super Genres** (Metal, Rock, Pop, Electronic, Jazz, Classical, Hip-Hop, Country, World) appear as buttons, auto-generated from the server's genre list — only genres with at least one matching keyword are shown.
|
||||
- Selecting a Super Genre fetches up to 50 songs distributed across all matched sub-genres in parallel, then shuffles the result.
|
||||
- **Progressive rendering**: the tracklist appears as soon as the first genre request returns — users with large Metal/Rock libraries no longer stare at a spinner for the entire fetch. A small inline spinner next to the title indicates that more genres are still loading.
|
||||
- **"Load 10 more"** button: fetches 10 additional songs from the same matched genres and appends them to the play queue.
|
||||
- Random playlist is automatically hidden while a Genre Mix is active.
|
||||
- Fetch timeout raised to **45 seconds** per genre request (was 15 s) and `Promise.allSettled` used so a single slow/failing genre does not abort the entire mix.
|
||||
|
||||
#### Queue Panel
|
||||
- **Shuffle button** in the queue header: Fisher-Yates shuffles all queued tracks while keeping the currently playing track at position 0. Button is disabled when the queue has fewer than 2 entries.
|
||||
|
||||
#### UI / UX
|
||||
- **LiveSearch keyboard navigation**: arrow keys navigate the dropdown, Enter selects the highlighted item or navigates to the full search results page, Escape closes the dropdown.
|
||||
- **Multi-line tooltip support**: add `data-tooltip-wrap` attribute to any element with `data-tooltip` to enable line-wrapping (uses `white-space: pre-line` + `\n` in the string). Respects a 220 px max-width.
|
||||
- **Genre column info icon** in Random Mix tracklist header: hover tooltip explains the clickable-genre-to-blacklist feature.
|
||||
- **Update link** in the sidebar now uses Tauri Shell plugin `open()` to launch the system browser correctly — `<a target="_blank">` has no effect inside a Tauri WebView.
|
||||
|
||||
### Fixed
|
||||
- **Songs skipping immediately** (root cause: Tauri v2 IPC maps Rust `snake_case` parameters to **camelCase** on the JS side — `duration_hint` must be `durationHint`). All `invoke()` calls updated.
|
||||
- **Play button doing nothing after restart**: `currentTrack` was `null` after restart (not persisted). Fixed by adding it to `partialize`.
|
||||
- **Position not restored after restart**: `initializeFromServerQueue` overwrote the local saved position with the server value even when the server reported 0. Now falls back to the localStorage value when the server position is 0.
|
||||
- **Genre Mix blank on Metal/Rock**: a single timed-out genre request caused `Promise.all` to reject the entire mix. Replaced with `Promise.allSettled` + 45 s timeout; partial results are shown immediately.
|
||||
- **Tooltip z-index**: tooltips in the main content area were rendered behind the queue panel. Fixed by giving `.main-content` `z-index: 1`, establishing a stacking context above the queue (which sits later in DOM order).
|
||||
- **Sidebar title clipping**: "Psysonic" brand text was truncated at narrow viewport widths. Minimum sidebar width raised from 180 px to 200 px.
|
||||
|
||||
### Changed
|
||||
- **Audio architecture**: Howler.js removed. All audio state (`isPlaying`, `isAudioPaused`, `currentTime`, `duration`) is now driven by Tauri events from the Rust engine rather than Howler callbacks.
|
||||
- **Random Mix layout**: Filter/blacklist panel and Genre Mix buttons are now combined in a two-column card at the top of the page instead of being scattered across the page.
|
||||
- **Hardcoded genre blacklist** extended with: `Fantasy`, `Comedy`, `Literature`.
|
||||
- **`getRandomSongs`** now accepts an optional `timeout` parameter (default 15 s) so callers can pass a longer value for large-library scenarios.
|
||||
|
||||
## [1.0.12] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **Seek Stop Bug**: Clicking the progress bar a second time no longer stops playback. Root cause: WebKit and GStreamer fire spurious `ended` events immediately after a direct `audioNode.currentTime` seek. A guard now checks `lastSeekAt` + playhead position to silently discard these false alarms.
|
||||
- **Play/Pause Hang**: Rapidly double-clicking the play/pause button no longer freezes the audio pipeline. A 300 ms lock prevents a second toggle from issuing `pause→play` before GStreamer has finished the previous state transition.
|
||||
- **Queue DnD (macOS / Windows)**: Drop target index is now calculated from the mouse `clientY` position at drop time instead of refs, eliminating the `dragend`-before-`drop` timing race on macOS WKWebView and Windows WebView2.
|
||||
|
||||
### Added
|
||||
- **Live Now Playing navigation**: Clicking an entry in the Live dropdown now navigates to the corresponding album page.
|
||||
|
||||
### Changed
|
||||
- **Hero blur**: Increased background blur in the Hero section for a more immersive look.
|
||||
|
||||
## [1.0.11] - 2026-03-14
|
||||
|
||||
### Added
|
||||
- **Search Results Page**: Pressing Enter in the search bar now navigates to a dedicated full search results page showing artists, albums, and songs with proper column layout and headers.
|
||||
|
||||
### Fixed
|
||||
- **Search Results Column Alignment**: Artist and album columns in the search results song list are now correctly aligned with their column headers.
|
||||
- **Search Results Header Alignment**: Fixed column header labels not aligning with song row content (root cause: `auto`-width Format column was sized independently per grid row).
|
||||
|
||||
### Changed
|
||||
- **Gapless Playback removed**: Removed the experimental gapless playback feature. It caused intermittent song skipping and beginning cutoffs and was not reliable enough to ship. Standard sequential playback is used instead.
|
||||
|
||||
### Known Issues
|
||||
- ~~**Seeking**: Seeking may occasionally be unreliable, particularly on Linux/GStreamer.~~ Fixed in 1.0.12.
|
||||
- ~~**Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop may not always work correctly on macOS and Windows.~~ Fixed in 1.0.12.
|
||||
|
||||
## [1.0.10] - 2026-03-14
|
||||
|
||||
### Added
|
||||
- **Active Track Highlighting**: The currently playing song is highlighted in album tracklists with a subtle pulsing accent background and a play icon — persists when navigating away and returning.
|
||||
- **Marquee Title in Fullscreen Player**: Long song titles now scroll smoothly as a marquee instead of being cut off.
|
||||
- **Clickable Artist / Album in Player Bar**: Clicking the artist name navigates to the artist page; clicking the song title navigates to the album page. Same behaviour in the Queue panel's now-playing strip.
|
||||
- **Linux App Menu Category**: Application now appears under **Multimedia** in desktop application menus (GNOME, KDE, etc.) instead of "Other".
|
||||
- **Windows MSI Upgrade Support**: Added stable `upgradeCode` GUID so the MSI installer recognises previous versions and upgrades in-place without requiring manual uninstallation first.
|
||||
|
||||
### Fixed
|
||||
- **Drag & Drop (macOS / Windows)**: Queue reordering now works correctly on macOS WKWebView and Windows WebView2. The previous fix cleared index refs synchronously in `onDragEnd`, which fires before `drop` on both platforms — refs are now cleared with a short delay so `onDropQueue` can read the correct source and destination indices.
|
||||
- **Settings Dropdowns**: Language and theme selects now have a clearly visible border (was invisible against the card background).
|
||||
- **Tracklist Format Column**: Removed file size and kHz from the format column — codec and bitrate only. Column moved to the far right, after duration. Width is now dynamic (`auto`).
|
||||
- **`tauri.conf.json`**: Fixed invalid placement of `shortDescription`/`longDescription` (were incorrectly nested under `bundle.linux`, now at `bundle` level). Removed invalid `nsis.allowDowngrades` field.
|
||||
|
||||
### Changed
|
||||
- **Favorites Icon**: Replaced the incorrect fork icon with a star icon in the Random Mix page, consistent with all other pages.
|
||||
- **Sidebar**: Removed drag-to-resize handle. Width now adapts dynamically to the viewport via `clamp(180px, 15vw, 220px)`.
|
||||
- **About Section**: Added "Developed with the support of Claude Code by Anthropic" credit. Fixed "weiterzugeben" wording in German MIT licence text.
|
||||
- **Minimize to Tray**: Now disabled by default.
|
||||
|
||||
## [1.0.9] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Gapless Playback**: The next track's audio pipeline is silently pre-warmed before the current track ends, eliminating the gap between songs — especially noticeable on live albums and concept records.
|
||||
- **Pre-caching**: Prefetched Howl instances are now actually reused for playback, giving near-instant track transitions instead of a new HTTP connection each time.
|
||||
- **Buffered Progress Indicator**: The seek bar now shows a secondary fill indicating how much of the current track has been buffered by the browser — visible in both the Player Bar and Fullscreen Player.
|
||||
- **Resume on Startup**: Pressing Play after launching the app now resumes the last track at the saved playback position instead of doing nothing.
|
||||
- **Album Track Hover Play Button**: Hovering over a track number in Album Detail reveals a play button for quick single-click playback.
|
||||
- **Ken Burns Background**: The Fullscreen Player background now slowly drifts and zooms (Ken Burns effect) for a more cinematic feel.
|
||||
- **F11 Fullscreen**: Toggle native borderless fullscreen with F11.
|
||||
- **Compact Queue Now-Playing**: The current track block in the Queue Panel is now a slim horizontal strip (72 px thumbnail) instead of a full-width cover, freeing up significantly more space for the queue list on smaller screens.
|
||||
|
||||
### Fixed
|
||||
- **GStreamer Seek Stability**: Implemented a three-layer recovery system for Linux/GStreamer seek hangs: (1) seek queuing to prevent overlapping GStreamer seeks, (2) a 2-second watchdog that triggers automatic recovery if a seek never completes, (3) an 8-second hang detector that silently recreates the audio pipeline and resumes from the last known position if playback freezes entirely.
|
||||
- **Fullscreen Player**: Removed drop shadow from cover art — looks cleaner on lighter artist backgrounds.
|
||||
|
||||
### Changed
|
||||
- **Hero Section**: Increased height (300 → 360 px) and cover art size (180 → 220 px) to prevent long album titles from clipping.
|
||||
- **Player Bar**: Controls and progress bar moved closer together for a more balanced layout.
|
||||
|
||||
## [1.0.8] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Ambient Stage**: Completely redesigned Fullscreen Player. Experience an immersive atmosphere with drifting color orbs, a "breathing" cover animation, and high-resolution artist backgrounds.
|
||||
- **Improved Drag & Drop**: Rewritten Play Queue reordering for rock-solid reliability on macOS (WKWebView) and Windows (WebView2).
|
||||
|
||||
### Fixed
|
||||
- **Linux Audio Stability**: Resolved playback stuttering when seeking under GStreamer by implementing a robust pause-seek-play sequence.
|
||||
- **Data Integration**: Standardized `artistId` propagation across all track sources for better metadata consistency.
|
||||
|
||||
## [1.0.7] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Update Notifications**: Integrated a native update check system in the sidebar that notifies you when a new version is available on GitHub.
|
||||
- **Improved Settings**: Refined layout and styling for a cleaner settings experience.
|
||||
|
||||
### Fixed
|
||||
- **UI/UX Refinements**: Polished sidebar animations and layout for better visual consistency.
|
||||
- **i18n**: Added missing translations for update notifications and system status.
|
||||
|
||||
## [1.0.6] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# 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,20 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2026 Psychotoxical
|
||||
Copyright (c) 2026 Psychotoxical
|
||||
|
||||
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.
|
||||
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 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 above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of 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
|
||||
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.
|
||||
|
||||
@@ -1,46 +1,41 @@
|
||||
<div align="center">
|
||||
<img src="public/logo-psysonic.png" alt="Psysonic Logo" width="200"/>
|
||||
<img src="public/logo.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: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></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://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) and [Nord](https://www.nordtheme.com/) aesthetics.
|
||||
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.
|
||||
|
||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
||||
|
||||
|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English and German.
|
||||
- 🎨 **Gorgeous UI**: Deeply integrated Catppuccin themes (Mocha & Latte) with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri, resulting in minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English and German (v1.0.0).
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
|
||||
- 🌊 **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.
|
||||
- 🔄 **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).
|
||||
- 💾 **Local Caching**: Fast loading times with customizable image caching thresholds.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums.
|
||||
- 🎛️ **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue. Server-side queue synchronization is fully supported.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux.
|
||||
|
||||
## ● Known Limitations
|
||||
## 🛡️ Security
|
||||
|
||||
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
|
||||
- **FLAC seeking**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated.
|
||||
With the **v1.0.0 Release**, Psysonic has been hardened for public use:
|
||||
- **Secured Credentials**: Industry-standard security using Tauri's native encrypted store.
|
||||
- **Hardened Sandbox**: Restricted filesystem permissions to only necessary directories.
|
||||
- **Native Security**: Disabled global Tauri objects to significantly reduce XSS attack vectors.
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
@@ -48,11 +43,7 @@ Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) p
|
||||
|
||||
- **Windows**: `.exe` or `.msi`
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
- **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.
|
||||
- **Linux**: `.AppImage` or `.deb`
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -99,6 +90,11 @@ Contributions are completely welcome! Whether it is translating the app into a n
|
||||
|
||||
## 📄 License
|
||||
|
||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
||||
Distributed under the MIT License. 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.
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<i>Core Components developed with <b>Claude Code</b></i><br/>
|
||||
<i>UI/UX developed with <b>Gemini 2.0 Flash</b> using <b>Antigravity</b></i>
|
||||
</p>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
@@ -15,10 +15,8 @@
|
||||
"@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",
|
||||
"howler": "^2.2.4",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -30,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/react": "^18.3.11",
|
||||
@@ -1523,15 +1522,6 @@
|
||||
"@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",
|
||||
@@ -1584,6 +1574,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/howler": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz",
|
||||
"integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/md5": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.6.tgz",
|
||||
@@ -1667,16 +1664,6 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-runtime": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^2.4.0",
|
||||
"regenerator-runtime": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -1724,27 +1711,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/butterchurn": {
|
||||
"version": "2.6.7",
|
||||
"resolved": "https://registry.npmjs.org/butterchurn/-/butterchurn-2.6.7.tgz",
|
||||
"integrity": "sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"ecma-proposal-math-extensions": "0.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/butterchurn-presets": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/butterchurn-presets/-/butterchurn-presets-2.4.7.tgz",
|
||||
"integrity": "sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"ecma-proposal-math-extensions": "0.0.2",
|
||||
"lodash": "^4.17.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1807,14 +1773,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crypt": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||
@@ -1872,12 +1830,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecma-proposal-math-extensions": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ecma-proposal-math-extensions/-/ecma-proposal-math-extensions-0.0.2.tgz",
|
||||
"integrity": "sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
@@ -2158,6 +2110,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/howler": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
|
||||
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
@@ -2236,12 +2194,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -2496,12 +2448,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.6.0",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,10 +18,8 @@
|
||||
"@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",
|
||||
"howler": "^2.2.4",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -33,6 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/react": "^18.3.11",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
src/
|
||||
pkg/
|
||||
*.tar.zst
|
||||
*.tar.gz
|
||||
@@ -1,65 +0,0 @@
|
||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||
pkgname=psysonic
|
||||
pkgver=1.6.0
|
||||
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/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "psysonic-$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-$pkgver"
|
||||
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
|
||||
#!/bin/sh
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec /usr/lib/psysonic/psysonic "\$@"
|
||||
EOF
|
||||
|
||||
# Desktop entry
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/psysonic.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=Psysonic
|
||||
Comment=Desktop music player for Subsonic API-compatible servers
|
||||
Exec=psysonic
|
||||
Icon=psysonic
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
EOF
|
||||
|
||||
# Icons
|
||||
install -Dm644 "src-tauri/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/psysonic.png"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 21 KiB |
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.6.0"
|
||||
version = "1.0.6"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -30,8 +30,3 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
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",
|
||||
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
||||
"shell:allow-open",
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
@@ -22,15 +22,9 @@
|
||||
"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"
|
||||
"core:window:allow-show"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","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"]}}
|
||||
{"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"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -6451,48 +6451,6 @@
|
||||
"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,48 +6451,6 @@
|
||||
"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: 30 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 998 B |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 829 KiB After Width: | Height: | Size: 985 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 34 KiB |
@@ -1,586 +0,0 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
||||
use rodio::{Decoder, Sink, Source};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
||||
|
||||
const EQ_BANDS_HZ: [f32; 10] = [31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||
const EQ_Q: f32 = 1.41;
|
||||
const EQ_CHECK_INTERVAL: usize = 1024;
|
||||
|
||||
struct EqSource<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
gains: Arc<[AtomicU32; 10]>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
filters: [[DirectForm2Transposed<f32>; 2]; 10],
|
||||
current_gains: [f32; 10],
|
||||
sample_counter: usize,
|
||||
channel_idx: usize,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> EqSource<S> {
|
||||
fn new(inner: S, gains: Arc<[AtomicU32; 10]>, enabled: Arc<AtomicBool>) -> Self {
|
||||
let sample_rate = inner.sample_rate();
|
||||
let channels = inner.channels();
|
||||
let filters = std::array::from_fn(|band| {
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate as f32 / 2.0) - 100.0);
|
||||
std::array::from_fn(|_| {
|
||||
let coeffs = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(0.0),
|
||||
(sample_rate as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
).unwrap_or_else(|_| Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(0.0),
|
||||
(sample_rate as f32).hz(),
|
||||
1000.0f32.hz(),
|
||||
EQ_Q,
|
||||
).unwrap());
|
||||
DirectForm2Transposed::<f32>::new(coeffs)
|
||||
})
|
||||
});
|
||||
Self {
|
||||
inner, sample_rate, channels, gains, enabled,
|
||||
filters,
|
||||
current_gains: [0.0; 10],
|
||||
sample_counter: 0,
|
||||
channel_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_if_needed(&mut self) {
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
if (gain_db - self.current_gains[band]).abs() > 0.01 {
|
||||
self.current_gains[band] = gain_db;
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate as f32 / 2.0) - 100.0);
|
||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(gain_db),
|
||||
(self.sample_rate as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
) {
|
||||
for ch in 0..2 {
|
||||
self.filters[band][ch].update_coefficients(coeffs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for EqSource<S> {
|
||||
type Item = f32;
|
||||
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next()?;
|
||||
|
||||
if self.sample_counter % EQ_CHECK_INTERVAL == 0 {
|
||||
self.refresh_if_needed();
|
||||
}
|
||||
self.sample_counter = self.sample_counter.wrapping_add(1);
|
||||
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
|
||||
return Some(sample);
|
||||
}
|
||||
|
||||
let ch = self.channel_idx.min(1);
|
||||
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
|
||||
|
||||
let mut s = sample;
|
||||
for band in 0..10 {
|
||||
s = self.filters[band][ch].run(s);
|
||||
}
|
||||
Some(s.clamp(-1.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for EqSource<S> {
|
||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
||||
fn channels(&self) -> u16 { self.channels }
|
||||
fn sample_rate(&self) -> u32 { self.sample_rate }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Reset biquad filter state to avoid glitches/clicks after seek.
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
self.current_gains[band] = gain_db;
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate as f32 / 2.0) - 100.0);
|
||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(gain_db),
|
||||
(self.sample_rate as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
) {
|
||||
for ch in 0..2 {
|
||||
self.filters[band][ch] = DirectForm2Transposed::<f32>::new(coeffs);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.channel_idx = 0;
|
||||
self.sample_counter = 0;
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Debug logger ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Engine state (registered as Tauri managed state) ────────────────────────
|
||||
|
||||
pub(crate) struct PreloadedTrack {
|
||||
url: String,
|
||||
data: Vec<u8>,
|
||||
duration_hint: f64,
|
||||
}
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub stream_handle: Arc<rodio::OutputStreamHandle>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
/// Monotonically incremented on each audio_play / audio_stop call.
|
||||
/// The background progress task captures its own `gen` at creation and
|
||||
/// bails out if this counter has moved on, preventing stale events.
|
||||
pub generation: Arc<AtomicU64>,
|
||||
pub http_client: reqwest::Client,
|
||||
pub eq_gains: Arc<[AtomicU32; 10]>,
|
||||
pub eq_enabled: Arc<AtomicBool>,
|
||||
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
pub crossfade_enabled: Arc<AtomicBool>,
|
||||
pub crossfade_secs: Arc<AtomicU32>, // f32 stored as bits
|
||||
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
/// The active rodio Sink. `None` when stopped.
|
||||
pub sink: Option<Sink>,
|
||||
pub duration_secs: f64,
|
||||
/// Position (seconds) that we seeked/resumed from.
|
||||
pub seek_offset: f64,
|
||||
/// Instant when we started counting from seek_offset (None when paused/stopped).
|
||||
pub play_started: Option<Instant>,
|
||||
/// Set when paused; holds the position at pause time.
|
||||
pub paused_at: Option<f64>,
|
||||
pub replay_gain_linear: f32,
|
||||
pub base_volume: f32,
|
||||
}
|
||||
|
||||
impl AudioCurrent {
|
||||
pub fn position(&self) -> f64 {
|
||||
if let Some(p) = self.paused_at {
|
||||
return p;
|
||||
}
|
||||
if let Some(t) = self.play_started {
|
||||
let elapsed = t.elapsed().as_secs_f64();
|
||||
(self.seek_offset + elapsed).min(self.duration_secs.max(0.001))
|
||||
} else {
|
||||
self.seek_offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise the audio engine. Spawns a dedicated thread that holds the
|
||||
/// `OutputStream` alive for the lifetime of the process (parking prevents
|
||||
/// the thread — and thus the stream — from being dropped).
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("psysonic-audio-stream".into())
|
||||
.spawn(move || match rodio::OutputStream::try_default() {
|
||||
Ok((_stream, handle)) => {
|
||||
tx.send(handle).ok();
|
||||
// Park forever — `_stream` must stay alive for audio to work.
|
||||
loop {
|
||||
std::thread::park();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[psysonic] audio output error: {e}");
|
||||
}
|
||||
})
|
||||
.expect("spawn audio stream thread");
|
||||
|
||||
let stream_handle = rx.recv().expect("audio stream handle");
|
||||
|
||||
let engine = AudioEngine {
|
||||
stream_handle: Arc::new(stream_handle),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs: 0.0,
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 0.8,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
}
|
||||
|
||||
// ─── Event payloads ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ProgressPayload {
|
||||
pub current_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Download and play the given URL. Replaces any currently playing track.
|
||||
/// Emits `audio:playing` (with duration as f64) once playback starts,
|
||||
/// then `audio:progress` every 500 ms, and `audio:ended` when done.
|
||||
#[tauri::command]
|
||||
pub async fn audio_play(
|
||||
url: String,
|
||||
volume: f32,
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Claim this generation — any in-flight progress task with the old gen will exit.
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Stop any previous fading sink unconditionally.
|
||||
{
|
||||
let mut fo = state.fading_out_sink.lock().unwrap();
|
||||
if let Some(old) = fo.take() {
|
||||
old.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: We intentionally do NOT stop the current Sink here.
|
||||
// The old Sink keeps playing while we download + decode the new track.
|
||||
// We swap Sinks atomically only after the new data is ready, so the
|
||||
// old track plays to completion (or as close to it as possible).
|
||||
|
||||
// ── Check preload cache or download ──────────────────────────────────────
|
||||
let data: Vec<u8> = {
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if let Some(p) = preloaded.as_ref().filter(|p| p.url == url) {
|
||||
let d = p.data.clone();
|
||||
let _ = p.duration_hint; // used for type check
|
||||
*preloaded = None;
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(cached_data) = cached {
|
||||
cached_data
|
||||
} else {
|
||||
let response = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||
}
|
||||
};
|
||||
|
||||
// Bail if superseded while downloading.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Trust the Subsonic API duration_hint as the primary source.
|
||||
// Decoder::total_duration() is unreliable for VBR MP3 (symphonia may
|
||||
// return a single-frame or header duration that is far too short).
|
||||
let decoder_duration = {
|
||||
let cursor = Cursor::new(data.clone());
|
||||
Decoder::new(cursor)
|
||||
.ok()
|
||||
.and_then(|d| d.total_duration())
|
||||
.map(|d| d.as_secs_f64())
|
||||
};
|
||||
let duration_secs = if duration_hint > 1.0 {
|
||||
duration_hint
|
||||
} else {
|
||||
decoder_duration.unwrap_or(duration_hint)
|
||||
};
|
||||
|
||||
let cursor = Cursor::new(data);
|
||||
let decoder = Decoder::new(cursor).map_err(|e| {
|
||||
app.emit("audio:error", e.to_string()).ok();
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
// Final generation check before committing.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Compute replay gain ───────────────────────────────────────────────────
|
||||
let gain_linear: f32 = replay_gain_db
|
||||
.map(|db| 10f32.powf(db / 20.0))
|
||||
.unwrap_or(1.0);
|
||||
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
|
||||
// Limit gain so peak sample doesn't exceed 1.0 (prevent clipping).
|
||||
let gain_linear = gain_linear.min(1.0 / peak);
|
||||
let effective_volume = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
|
||||
|
||||
// ── Create new sink ───────────────────────────────────────────────────────
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
|
||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||
sink.set_volume(effective_volume);
|
||||
let eq_source = EqSource::new(
|
||||
decoder.convert_samples::<f32>(),
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
);
|
||||
sink.append(eq_source);
|
||||
|
||||
// Atomically swap sinks: take old one, install new one.
|
||||
// Capture old volume so the crossfade fade-out starts at the right level.
|
||||
let (old_sink, old_vol) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0);
|
||||
let old = cur.sink.take();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
(old, old_vol)
|
||||
};
|
||||
|
||||
// Handle old sink: crossfade fade-out or immediate stop.
|
||||
// The new track is already playing; the old sink runs in parallel during a crossfade.
|
||||
if crossfade_enabled {
|
||||
if let Some(old) = old_sink {
|
||||
// Park the old sink in fading_out_sink so a subsequent audio_play can cancel it.
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
tokio::spawn(async move {
|
||||
let steps: u32 = 30;
|
||||
let step_ms = ((crossfade_secs_val * 1000.0) / steps as f32) as u64;
|
||||
for i in (0..=steps).rev() {
|
||||
{
|
||||
let fo = fo_arc.lock().unwrap();
|
||||
match fo.as_ref() {
|
||||
Some(s) => s.set_volume(old_vol * (i as f32 / steps as f32)),
|
||||
None => return, // cancelled by a subsequent audio_play
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(step_ms)).await;
|
||||
}
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
|
||||
// ── Progress + ended detection ────────────────────────────────────────────
|
||||
// We do NOT use `sink.empty()` because in rodio 0.19 the source moves from
|
||||
// the pending queue to the active state almost immediately after `append()`,
|
||||
// making `empty()` return `true` within milliseconds even for long tracks.
|
||||
//
|
||||
// Instead we use the wall-clock position (seek_offset + elapsed).
|
||||
// `AudioCurrent::position()` is clamped to `duration_secs`, so once it
|
||||
// reaches the end it stays there. We fire `audio:ended` after two
|
||||
// consecutive ticks where position >= duration - threshold, where threshold
|
||||
// is extended to crossfade_secs when crossfade is enabled so the frontend
|
||||
// gets time to start the next track during the fade.
|
||||
let gen_counter = state.generation.clone();
|
||||
let current_arc = state.current.clone();
|
||||
let app_clone = app.clone();
|
||||
let crossfade_enabled_arc = state.crossfade_enabled.clone();
|
||||
let crossfade_secs_arc = state.crossfade_secs.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||
break;
|
||||
}
|
||||
|
||||
let (pos, dur, is_paused) = {
|
||||
let cur = current_arc.lock().unwrap();
|
||||
(cur.position(), cur.duration_secs, cur.paused_at.is_some())
|
||||
};
|
||||
|
||||
app_clone
|
||||
.emit(
|
||||
"audio:progress",
|
||||
ProgressPayload { current_time: pos, duration: dur },
|
||||
)
|
||||
.ok();
|
||||
|
||||
if is_paused {
|
||||
// Don't advance near-end counter while paused (stay put).
|
||||
continue;
|
||||
}
|
||||
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
|
||||
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
|
||||
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
|
||||
|
||||
if dur > end_threshold && pos >= dur - end_threshold {
|
||||
near_end_ticks += 1;
|
||||
if near_end_ticks >= 2 {
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
app_clone.emit("audio:ended", ()).ok();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
near_end_ticks = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if !sink.is_paused() {
|
||||
let pos = cur.position();
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_resume(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() {
|
||||
sink.stop();
|
||||
}
|
||||
cur.duration_secs = 0.0;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
|
||||
.map_err(|e: rodio::source::SeekError| e.to_string())?;
|
||||
if cur.paused_at.is_some() {
|
||||
cur.paused_at = Some(seconds);
|
||||
} else {
|
||||
cur.seek_offset = seconds;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.eq_enabled.store(enabled, Ordering::Relaxed);
|
||||
for (i, &gain) in gains.iter().enumerate() {
|
||||
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Don't re-download if already preloaded for this URL.
|
||||
{
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let response = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Ok(()); // silently fail preload
|
||||
}
|
||||
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data, duration_hint });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
@@ -19,13 +17,8 @@ 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())
|
||||
@@ -109,26 +102,12 @@ pub fn run() {
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
// 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", ());
|
||||
}
|
||||
// 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", ());
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
exit_app,
|
||||
audio::audio_play,
|
||||
audio::audio_pause,
|
||||
audio::audio_resume,
|
||||
audio::audio_stop,
|
||||
audio::audio_seek,
|
||||
audio::audio_set_volume,
|
||||
audio::audio_set_eq,
|
||||
audio::audio_preload,
|
||||
audio::audio_set_crossfade,
|
||||
])
|
||||
.invoke_handler(tauri::generate_handler![greet, exit_app])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.6.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"version": "1.0.6",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@@ -22,8 +22,7 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": true,
|
||||
"dragDropEnabled": false
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -46,18 +45,10 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"category": "Music",
|
||||
"shortDescription": "A sleek music player for Subsonic-compatible servers",
|
||||
"longDescription": "Psysonic is a desktop music player for Subsonic-compatible servers such as Navidrome and Gonic. It features a modern Catppuccin-themed UI with glassmorphism effects, gapless playback, a fullscreen ambient player, play queue management with drag-and-drop, scrobbling support, and multi-server profiles.",
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { PanelRight, PanelRightClose } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import PlayerBar from './components/PlayerBar';
|
||||
import LiveSearch from './components/LiveSearch';
|
||||
@@ -25,18 +23,11 @@ import Statistics from './pages/Statistics';
|
||||
import Playlists from './pages/Playlists';
|
||||
import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useEqStore } from './store/eqStore';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
@@ -45,24 +36,17 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
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 {
|
||||
@@ -81,10 +65,12 @@ function AppShell() {
|
||||
fn();
|
||||
}, [currentTrack, isPlaying]);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(220);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
|
||||
});
|
||||
const [queueWidth, setQueueWidth] = useState(340);
|
||||
const [queueWidth, setQueueWidth] = useState(300);
|
||||
const [isDraggingSidebar, setIsDraggingSidebar] = useState(false);
|
||||
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,18 +78,24 @@ function AppShell() {
|
||||
}, [isSidebarCollapsed]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (isDraggingQueue) {
|
||||
if (isDraggingSidebar) {
|
||||
// Limit sidebar width between 180px and 400px
|
||||
const newWidth = Math.max(180, Math.min(e.clientX, 400));
|
||||
setSidebarWidth(newWidth);
|
||||
} else if (isDraggingQueue) {
|
||||
// Limit queue width between 250px and 500px. Queue is on the right.
|
||||
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
|
||||
setQueueWidth(newWidth);
|
||||
}
|
||||
}, [isDraggingQueue]);
|
||||
}, [isDraggingSidebar, isDraggingQueue]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDraggingSidebar(false);
|
||||
setIsDraggingQueue(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingQueue) {
|
||||
if (isDraggingSidebar || isDraggingQueue) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
@@ -119,13 +111,13 @@ function AppShell() {
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.classList.remove('is-dragging');
|
||||
};
|
||||
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
|
||||
}, [isDraggingSidebar, isDraggingQueue, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
style={{
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
|
||||
style={{
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : `${sidebarWidth}px`,
|
||||
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
@@ -134,28 +126,21 @@ function AppShell() {
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
<div
|
||||
className="resizer resizer-sidebar"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingSidebar(true);
|
||||
}}
|
||||
style={{ display: isSidebarCollapsed ? 'none' : 'block' }}
|
||||
/>
|
||||
<main className="main-content">
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<NowPlayingDropdown />
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={toggleQueue}
|
||||
title={t('player.toggleQueue')}
|
||||
>
|
||||
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
|
||||
</button>
|
||||
</header>
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineOverlay
|
||||
serverName={serverName}
|
||||
onRetry={connRetry}
|
||||
isChecking={connRetrying}
|
||||
/>
|
||||
)}
|
||||
<div className="content-body" style={{ padding: 0 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
@@ -168,9 +153,7 @@ function AppShell() {
|
||||
<Route path="/random-mix" element={<RandomMix />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<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>
|
||||
@@ -190,7 +173,6 @@ function AppShell() {
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
<DownloadFolderModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,15 +184,9 @@ function TauriEventBridge() {
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const { minimizeToTray } = useAuthStore();
|
||||
|
||||
// Spacebar → play/pause, F11 → window fullscreen
|
||||
// Spacebar → play/pause (ignore when focus is in an input)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.code === 'F11') {
|
||||
e.preventDefault();
|
||||
const win = getCurrentWindow();
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
if (e.code !== 'Space') return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
@@ -255,10 +231,6 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
|
||||
@@ -59,7 +59,6 @@ export interface SubsonicSong {
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
artistId?: string;
|
||||
duration: number;
|
||||
track?: number;
|
||||
discNumber?: number;
|
||||
@@ -74,15 +73,6 @@ export interface SubsonicSong {
|
||||
samplingRate?: number;
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
path?: string;
|
||||
albumArtist?: string;
|
||||
replayGain?: {
|
||||
trackGain?: number;
|
||||
albumGain?: number;
|
||||
trackPeak?: number;
|
||||
albumPeak?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
@@ -169,22 +159,11 @@ export async function getAlbumList(
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size, _t: Date.now() };
|
||||
if (genre) params.genre = genre;
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||
export async function getRandomSongs(size = 50): Promise<SubsonicSong[]> {
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', { size });
|
||||
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;
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -13,14 +12,16 @@ 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">
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
>
|
||||
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
@@ -30,14 +31,14 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
<Users size={32} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
<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 className="artist-card-name" data-tooltip={artist.name}>
|
||||
{artist.name}
|
||||
</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="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<section className="artist-row-section">
|
||||
<div className="artist-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="album-row-nav">
|
||||
<div className="artist-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
|
||||
@@ -10,9 +10,7 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
let cancelled = false;
|
||||
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
|
||||
return () => { cancelled = true; };
|
||||
getCachedUrl(fetchUrl, cacheKey).then(setResolved);
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return resolved || fetchUrl;
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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,10 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User } 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';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
@@ -22,7 +21,6 @@ export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -64,7 +62,7 @@ export default function ContextMenu() {
|
||||
const top = await getTopSongs(artistName);
|
||||
const radioTracks = [...top, ...similar].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,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
playTrack(radioTracks[0], radioTracks);
|
||||
@@ -76,16 +74,25 @@ export default function ContextMenu() {
|
||||
|
||||
const downloadAlbum = async (albumName: string, albumId: string) => {
|
||||
try {
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${sanitizeFilename(albumName)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
}
|
||||
@@ -130,7 +137,7 @@ export default function ContextMenu() {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
const tracks = 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,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
@@ -139,11 +146,6 @@ 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>
|
||||
@@ -203,14 +205,6 @@ 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>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function DownloadFolderModal() {
|
||||
const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore();
|
||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('common.chooseDownloadFolder') });
|
||||
if (selected && typeof selected === 'string') setFolder(selected);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={cancel} style={{ zIndex: 210 }} />
|
||||
<div className="eq-popup" style={{ zIndex: 211, width: 'min(480px, 92vw)', gap: 0 }}>
|
||||
<div className="eq-popup-header">
|
||||
<span className="eq-popup-title">{t('common.chooseDownloadFolder')}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 0 12px' }}>
|
||||
<div className="download-folder-pick-row">
|
||||
<span className="download-folder-path">
|
||||
{folder || t('common.noFolderSelected')}
|
||||
</span>
|
||||
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={15} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="download-remember-row">
|
||||
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
|
||||
<span>{t('common.rememberDownloadFolder')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="download-modal-actions">
|
||||
<button className="btn btn-ghost" onClick={cancel}>{t('common.cancel')}</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => confirm(setDownloadFolder)}
|
||||
disabled={!folder}
|
||||
>
|
||||
{t('common.download')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
// ─── Frequency response canvas ────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const EQ_Q = 1.41;
|
||||
|
||||
function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number {
|
||||
if (Math.abs(gainDb) < 0.01) return 0;
|
||||
const w0 = (2 * Math.PI * centerHz) / sampleRate;
|
||||
const A = Math.pow(10, gainDb / 40);
|
||||
const alpha = Math.sin(w0) / (2 * EQ_Q);
|
||||
const b0 = 1 + alpha * A;
|
||||
const b1 = -2 * Math.cos(w0);
|
||||
const b2 = 1 - alpha * A;
|
||||
const a0 = 1 + alpha / A;
|
||||
const a1 = -2 * Math.cos(w0);
|
||||
const a2 = 1 - alpha / A;
|
||||
const w = (2 * Math.PI * freq) / sampleRate;
|
||||
const cosW = Math.cos(w), sinW = Math.sin(w);
|
||||
const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w);
|
||||
const numRe = b0 + b1 * cosW + b2 * cos2W;
|
||||
const numIm = - b1 * sinW - b2 * sin2W;
|
||||
const denRe = a0 + a1 * cosW + a2 * cos2W;
|
||||
const denIm = - a1 * sinW - a2 * sin2W;
|
||||
const numMag2 = numRe * numRe + numIm * numIm;
|
||||
const denMag2 = denRe * denRe + denIm * denIm;
|
||||
return 10 * Math.log10(numMag2 / denMag2);
|
||||
}
|
||||
|
||||
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.offsetWidth;
|
||||
const H = canvas.offsetHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const fMin = 20, fMax = 20000;
|
||||
const dbMin = -13, dbMax = 13;
|
||||
const padL = 36, padR = 8, padT = 8, padB = 1;
|
||||
const innerW = W - padL - padR;
|
||||
const innerH = H - padT - padB;
|
||||
|
||||
const freqToX = (f: number) =>
|
||||
padL + (Math.log10(f / fMin) / Math.log10(fMax / fMin)) * innerW;
|
||||
const dbToY = (db: number) =>
|
||||
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Grid: dB lines
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.lineWidth = 1;
|
||||
[-12, -6, 0, 6, 12].forEach(db => {
|
||||
const y = dbToY(db);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, y);
|
||||
ctx.lineTo(W - padR, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
|
||||
});
|
||||
|
||||
// Grid: frequency lines
|
||||
[31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].forEach(f => {
|
||||
const x = freqToX(f);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padT);
|
||||
ctx.lineTo(x, H - padB);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// Zero line (brighter)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, dbToY(0));
|
||||
ctx.lineTo(W - padR, dbToY(0));
|
||||
ctx.stroke();
|
||||
|
||||
// Frequency response curve
|
||||
const points: [number, number][] = [];
|
||||
const steps = innerW * 2;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = fMin * Math.pow(fMax / fMin, i / steps);
|
||||
let totalDb = 0;
|
||||
for (let band = 0; band < 10; band++) {
|
||||
totalDb += biquadPeakResponse(f, EQ_BANDS[band].freq, gains[band], SAMPLE_RATE);
|
||||
}
|
||||
totalDb = Math.max(dbMin, Math.min(dbMax, totalDb));
|
||||
points.push([freqToX(f), dbToY(totalDb)]);
|
||||
}
|
||||
|
||||
// Fill under curve
|
||||
const grad = ctx.createLinearGradient(0, padT, 0, H);
|
||||
grad.addColorStop(0, accentColor.replace(')', ', 0.25)').replace('rgb', 'rgba'));
|
||||
grad.addColorStop(1, accentColor.replace(')', ', 0.0)').replace('rgb', 'rgba'));
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], dbToY(0));
|
||||
points.forEach(([x, y]) => ctx.lineTo(x, y));
|
||||
ctx.lineTo(points[points.length - 1][0], dbToY(0));
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// Curve line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
points.forEach(([x, y]) => ctx.lineTo(x, y));
|
||||
ctx.strokeStyle = accentColor;
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ─── Custom vertical fader (no native range input) ────────────────────────────
|
||||
|
||||
const GAIN_MIN = -12, GAIN_MAX = 12;
|
||||
|
||||
interface FaderProps {
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
function VerticalFader({ value, disabled, onChange }: FaderProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom
|
||||
const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN);
|
||||
|
||||
const updateFromY = useCallback((clientY: number) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
dragging.current = true;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragging.current || disabled) return;
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerUp = () => { dragging.current = false; };
|
||||
|
||||
const thumbPct = gainToPct(value) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="eq-fader-custom"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="eq-track-line" />
|
||||
<div className="eq-thumb" style={{ top: `${thumbPct}%`, opacity: disabled ? 0.3 : 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Equalizer() {
|
||||
const { t } = useTranslation();
|
||||
const gains = useEqStore(s => s.gains);
|
||||
const enabled = useEqStore(s => s.enabled);
|
||||
const activePreset = useEqStore(s => s.activePreset);
|
||||
const customPresets = useEqStore(s => s.customPresets);
|
||||
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
|
||||
|
||||
const [saveName, setSaveName] = useState('');
|
||||
const [showSave, setShowSave] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
|
||||
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
|
||||
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
|
||||
drawCurve(canvas, gains, accent, bg, text);
|
||||
}, [gains, theme]);
|
||||
|
||||
useEffect(() => { redraw(); }, [redraw]);
|
||||
|
||||
useEffect(() => {
|
||||
const ro = new ResizeObserver(redraw);
|
||||
if (canvasRef.current) ro.observe(canvasRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
|
||||
const selectValue = activePreset ?? '__custom__';
|
||||
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
|
||||
|
||||
const handleSave = () => {
|
||||
const name = saveName.trim();
|
||||
if (!name) return;
|
||||
saveCustomPreset(name);
|
||||
setSaveName('');
|
||||
setShowSave(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="eq-wrap">
|
||||
{/* Controls bar */}
|
||||
<div className="eq-controls-bar">
|
||||
<label className="eq-toggle-label">
|
||||
<span>{t('settings.eqEnabled')}</span>
|
||||
<label className="toggle-switch" style={{ marginLeft: 8 }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<div className="eq-preset-row">
|
||||
<select
|
||||
className="input eq-preset-select"
|
||||
value={selectValue}
|
||||
onChange={e => applyPreset(e.target.value)}
|
||||
>
|
||||
{activePreset === null && <option value="__custom__">{t('settings.eqPresetCustom')}</option>}
|
||||
<optgroup label={t('settings.eqPresetBuiltin')}>
|
||||
{BUILTIN_PRESETS.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
||||
</optgroup>
|
||||
{customPresets.length > 0 && (
|
||||
<optgroup label={t('settings.eqPresetCustomGroup')}>
|
||||
{customPresets.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
|
||||
{isCustomSaved && (
|
||||
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} title={t('settings.eqDeletePreset')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} title={t('settings.eqResetBands')}>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} title={t('settings.eqSavePreset')}>
|
||||
<Save size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSave && (
|
||||
<div className="eq-save-row">
|
||||
<input
|
||||
type="text" className="input" placeholder={t('settings.eqPresetName')}
|
||||
value={saveName} onChange={e => setSaveName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||
autoFocus style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={!saveName.trim()}>{t('common.save')}</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setShowSave(false); setSaveName(''); }}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EQ panel */}
|
||||
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
|
||||
{/* Frequency response */}
|
||||
<canvas ref={canvasRef} className="eq-canvas" />
|
||||
|
||||
{/* Fader area */}
|
||||
<div className="eq-faders">
|
||||
{/* dB scale */}
|
||||
<div className="eq-db-scale">
|
||||
{[12, 6, 0, -6, -12].map(db => (
|
||||
<span key={db} className="eq-db-tick">
|
||||
{db > 0 ? `+${db}` : db}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bands */}
|
||||
{EQ_BANDS.map((band, i) => (
|
||||
<div key={band.freq} className="eq-band">
|
||||
<span className="eq-gain-val">
|
||||
{gains[i] > 0 ? '+' : ''}{gains[i].toFixed(1)}
|
||||
</span>
|
||||
<div className="eq-fader-track">
|
||||
<div className="eq-zero-mark" />
|
||||
<VerticalFader
|
||||
value={gains[i]}
|
||||
disabled={!enabled}
|
||||
onChange={v => setBandGain(i, v)}
|
||||
/>
|
||||
</div>
|
||||
<span className="eq-freq-label">{band.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import VisualizerCanvas from './VisualizerCanvas';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -16,45 +15,9 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function MarqueeTitle({ title }: { title: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [scrollAmount, setScrollAmount] = useState(0);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
// Temporarily make span inline-block to get its natural width
|
||||
text.style.display = 'inline-block';
|
||||
const textWidth = text.getBoundingClientRect().width;
|
||||
text.style.display = '';
|
||||
const overflow = textWidth - container.clientWidth;
|
||||
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (containerRef.current) ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [title, measure]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fs-title-wrap">
|
||||
<span
|
||||
ref={textRef}
|
||||
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
|
||||
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Crossfading blurred background ───────────────────────────────────────────
|
||||
// ─── Crossfading blurred background — two stacked divs for true crossfade ───
|
||||
const FsBg = memo(function FsBg({ url }: { url: string }) {
|
||||
// Each layer: {url, id, visible}
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
@@ -63,10 +26,15 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
const id = counterRef.current++;
|
||||
// Add the new layer (opacity 0)
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
// One frame later: make new layer visible (0→1) and old ones invisible (1→0)
|
||||
const t1 = setTimeout(() => {
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
setLayers(prev =>
|
||||
prev.map(l => ({ ...l, visible: l.id === id }))
|
||||
);
|
||||
}, 20);
|
||||
// After transition: clean up old layers
|
||||
const t2 = setTimeout(() => {
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 800);
|
||||
@@ -87,20 +55,13 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
|
||||
// ─── Isolated progress sub-component (re-renders every tick, nothing else does) ───
|
||||
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
||||
[seek]
|
||||
);
|
||||
|
||||
const pct = progress * 100;
|
||||
const buf = Math.max(pct, buffered * 100);
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)), [seek]);
|
||||
|
||||
return (
|
||||
<div className="fs-progress-wrap">
|
||||
@@ -110,10 +71,7 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={{
|
||||
'--pct': `${pct}%`,
|
||||
'--buf': `${buf}%`,
|
||||
} as React.CSSProperties}
|
||||
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
|
||||
aria-label="progress"
|
||||
/>
|
||||
</div>
|
||||
@@ -122,7 +80,9 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
|
||||
|
||||
|
||||
// ─── Isolated play/pause button (subscribes to isPlaying only) ───
|
||||
const FsPlayBtn = memo(function FsPlayBtn() {
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
@@ -134,45 +94,30 @@ const FsPlayBtn = memo(function FsPlayBtn() {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Main component ────────────────────────────────────────────────────────────
|
||||
interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
const [vizActive, setVizActive] = useState(false);
|
||||
const [nextPresetTrigger, setNextPresetTrigger] = useState(0);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
// useCachedUrl must be called unconditionally (hook rules)
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
|
||||
|
||||
// Fetch artist image for background — fall back to cover art if unavailable
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
useEffect(() => {
|
||||
setArtistBgUrl('');
|
||||
const artistId = currentTrack?.artistId;
|
||||
if (!artistId) return;
|
||||
let cancelled = false;
|
||||
getArtistInfo(artistId).then(info => {
|
||||
if (!cancelled && info.largeImageUrl) setArtistBgUrl(info.largeImageUrl);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -181,116 +126,88 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
{/* Crossfading blurred background */}
|
||||
<FsBg url={resolvedCoverUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
|
||||
{/* Layer 1 — blurred artist image OR visualizer */}
|
||||
{vizActive && currentTrack ? (
|
||||
<VisualizerCanvas
|
||||
trackId={currentTrack.id}
|
||||
nextPresetTrigger={nextPresetTrigger}
|
||||
onPresetName={setPresetName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-1" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-2" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-3" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
{/* Close button */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')} data-tooltip-pos="bottom">
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Visualizer toggle + preset controls */}
|
||||
<div style={{ position: 'absolute', top: '1.25rem', right: '4rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{vizActive && (
|
||||
<>
|
||||
{presetName && (
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{presetName}
|
||||
{/* Two-column layout: left = cover + info + controls, right = playlist */}
|
||||
<div className="fs-layout">
|
||||
|
||||
{/* Left column */}
|
||||
<div className="fs-left">
|
||||
<div className="fs-cover-wrap">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={coverKey} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
|
||||
) : (
|
||||
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fs-track-info">
|
||||
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
<p className="fs-album">{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}</p>
|
||||
{(currentTrack?.bitRate || currentTrack?.suffix) && (
|
||||
<span className="fs-codec">
|
||||
{[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="fs-btn fs-btn-sm"
|
||||
onClick={() => setNextPresetTrigger(n => n + 1)}
|
||||
aria-label={t('player.nextPreset')}
|
||||
data-tooltip={t('player.nextPreset')}
|
||||
style={{ color: 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
<Shuffle size={14} />
|
||||
</div>
|
||||
|
||||
<FsProgress duration={duration} />
|
||||
|
||||
<div className="fs-controls">
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
</>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: upcoming tracks */}
|
||||
{upcoming.length > 0 && (
|
||||
<div className="fs-right">
|
||||
<h2 className="fs-upcoming-title">{t('queue.nextTracks')}</h2>
|
||||
<div className="fs-upcoming-list">
|
||||
{upcoming.map((track, i) => (
|
||||
<button
|
||||
key={`${track.id}-${queueIndex + 1 + i}`}
|
||||
className="fs-upcoming-item"
|
||||
onClick={() => playTrack(track, queue)}
|
||||
>
|
||||
{track.coverArt ? (
|
||||
<CachedImage src={buildCoverArtUrl(track.coverArt, 80)} cacheKey={coverArtCacheKey(track.coverArt, 80)} alt="" className="fs-upcoming-art" />
|
||||
) : (
|
||||
<div className="fs-upcoming-art fs-upcoming-placeholder"><Music size={14} /></div>
|
||||
)}
|
||||
<div className="fs-upcoming-info">
|
||||
<span className="fs-upcoming-name">{track.title}</span>
|
||||
<span className="fs-upcoming-artist">{track.artist}</span>
|
||||
</div>
|
||||
<span className="fs-upcoming-dur">{formatTime(track.duration)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${vizActive ? 'active' : ''}`}
|
||||
onClick={() => setVizActive(v => !v)}
|
||||
aria-label={t('player.visualizer')}
|
||||
data-tooltip={t('player.visualizer')}
|
||||
style={{ color: vizActive ? 'white' : 'rgba(255,255,255,0.5)' }}
|
||||
>
|
||||
<AudioWaveform size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center stage — everything vertically + horizontally centered */}
|
||||
<div className="fs-stage">
|
||||
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
<div className="fs-cover-wrap">
|
||||
{coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={`${currentTrack?.album} Cover`}
|
||||
className="fs-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fs-track-info">
|
||||
<MarqueeTitle title={currentTrack?.title ?? '—'} />
|
||||
<p className="fs-album">
|
||||
{currentTrack?.album ?? ''}
|
||||
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
|
||||
</p>
|
||||
{(currentTrack?.bitRate || currentTrack?.suffix) && (
|
||||
<span className="fs-codec">
|
||||
{[
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
|
||||
].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FsProgress duration={duration} />
|
||||
|
||||
<div className="fs-controls">
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,11 +42,7 @@ function HeroBg({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface HeroProps {
|
||||
albums?: SubsonicAlbum[];
|
||||
}
|
||||
|
||||
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
export default function Hero() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -54,9 +50,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
||||
}, [albumsProp]);
|
||||
}, []);
|
||||
|
||||
// Start / restart auto-advance timer
|
||||
const startTimer = useCallback((len: number) => {
|
||||
@@ -138,7 +133,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = 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,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
|
||||
@@ -19,11 +19,9 @@ export default function LiveSearch() {
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const doSearch = useCallback(
|
||||
debounce(async (q: string) => {
|
||||
@@ -40,7 +38,7 @@ export default function LiveSearch() {
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
|
||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
@@ -53,40 +51,6 @@ export default function LiveSearch() {
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
|
||||
// Flat list of all navigable items for keyboard nav
|
||||
const flatItems = results ? [
|
||||
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.songs.map(s => ({ id: s.id, action: () => {
|
||||
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
|
||||
setOpen(false); setQuery('');
|
||||
}}))),
|
||||
] : [];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || !flatItems.length) {
|
||||
if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = Math.min(activeIndex + 1, flatItems.length - 1);
|
||||
setActiveIndex(next);
|
||||
dropdownRef.current?.querySelectorAll<HTMLElement>('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = Math.max(activeIndex - 1, -1);
|
||||
setActiveIndex(next);
|
||||
if (next >= 0) dropdownRef.current?.querySelectorAll<HTMLElement>('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0) { flatItems[activeIndex].action(); setActiveIndex(-1); }
|
||||
else if (query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false); setActiveIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="live-search" ref={ref} role="search">
|
||||
<div className="live-search-input-wrap">
|
||||
@@ -105,7 +69,6 @@ export default function LiveSearch() {
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => results && setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
@@ -119,79 +82,78 @@ export default function LiveSearch() {
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
|
||||
<div className="live-search-dropdown" id="search-results" role="listbox">
|
||||
{!hasResults && !loading && (
|
||||
<div className="search-empty">{t('search.noResults', { query })}</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
let idx = 0;
|
||||
return <>
|
||||
{results?.artists.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
|
||||
{results.artists.map(a => {
|
||||
const i = idx++;
|
||||
return (
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
<div className="search-result-icon"><Users size={14} /></div>
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{results?.artists.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
|
||||
{results.artists.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option"
|
||||
>
|
||||
<div className="search-result-icon"><Users size={14} /></div>
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.albums.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
|
||||
{results.albums.map(a => {
|
||||
const i = idx++;
|
||||
return (
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
{a.coverArt ? (
|
||||
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||
)}
|
||||
<div>
|
||||
<div className="search-result-name">{a.name}</div>
|
||||
<div className="search-result-sub">{a.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{results?.albums.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
|
||||
{results.albums.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option"
|
||||
>
|
||||
{a.coverArt ? (
|
||||
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||
)}
|
||||
<div>
|
||||
<div className="search-result-name">{a.name}</div>
|
||||
<div className="search-result-sub">{a.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.songs.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
|
||||
{results.songs.map(s => {
|
||||
const i = idx++;
|
||||
return (
|
||||
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
|
||||
setOpen(false); setQuery('');
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
<div className="search-result-icon"><Music size={14} /></div>
|
||||
<div>
|
||||
<div className="search-result-name">{s.title}</div>
|
||||
<div className="search-result-sub">{s.artist} · {s.album}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</>;
|
||||
})()}
|
||||
{results?.songs.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
|
||||
{results.songs.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
className="search-result-item"
|
||||
onClick={() => {
|
||||
playTrack({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating
|
||||
});
|
||||
setOpen(false); setQuery('');
|
||||
}}
|
||||
role="option"
|
||||
>
|
||||
<div className="search-result-icon"><Music size={14} /></div>
|
||||
<div>
|
||||
<div className="search-result-name">{s.title}</div>
|
||||
<div className="search-result-sub">{s.artist} · {s.album}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,9 @@ import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subs
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function NowPlayingDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
@@ -123,11 +121,7 @@ export default function NowPlayingDropdown() {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{visible.map((stream, idx) => (
|
||||
<div
|
||||
key={`${stream.id}-${idx}`}
|
||||
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
|
||||
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div key={`${stream.id}-${idx}`} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px' }}>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
{stream.coverArt ? (
|
||||
<img src={buildCoverArtUrl(stream.coverArt, 100)} alt="Cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WifiOff, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
serverName: string;
|
||||
onRetry: () => void;
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export default function OfflineOverlay({ serverName, onRetry, isChecking }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="offline-overlay">
|
||||
<div className="offline-overlay-card">
|
||||
<WifiOff size={48} className="offline-icon" />
|
||||
<h2 className="offline-title">{t('connection.offlineTitle')}</h2>
|
||||
<p className="offline-subtitle">
|
||||
{t('connection.offlineSubtitle', { server: serverName })}
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary offline-retry"
|
||||
onClick={onRetry}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw size={16} className={isChecking ? 'spin' : ''} />
|
||||
{isChecking ? t('connection.checking') : t('connection.retry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, List, Square, Repeat, Repeat1, Maximize2
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -20,29 +16,30 @@ function formatTime(seconds: number): string {
|
||||
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [eqOpen, setEqOpen] = useState(false);
|
||||
const {
|
||||
currentTrack, isPlaying, currentTime, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
} = usePlayerStore();
|
||||
const { currentTrack, isPlaying, progress, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
seek(parseFloat(e.target.value));
|
||||
}, [seek]);
|
||||
|
||||
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
}, [setVolume]);
|
||||
|
||||
const progressStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${progress * 100}%, var(--ctp-surface2) ${progress * 100}%)`,
|
||||
};
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="player-track-info">
|
||||
<div
|
||||
@@ -69,114 +66,97 @@ export default function PlayerBar() {
|
||||
)}
|
||||
</div>
|
||||
<div className="player-track-meta">
|
||||
<div
|
||||
className="player-track-name"
|
||||
data-tooltip={currentTrack?.title ?? ''}
|
||||
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>
|
||||
<div className="player-track-name" data-tooltip={currentTrack?.title ?? ''}>
|
||||
{currentTrack?.title ?? t('player.noTitle')}
|
||||
</div>
|
||||
<div
|
||||
className="player-track-artist"
|
||||
data-tooltip={currentTrack?.artist ?? ''}
|
||||
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>
|
||||
<div className="player-track-artist" data-tooltip={currentTrack?.artist ?? ''}>
|
||||
{currentTrack?.artist ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="player-buttons">
|
||||
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Controls + Progress */}
|
||||
<div className="player-controls">
|
||||
<div className="player-buttons">
|
||||
<button className="player-btn" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={16} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
{/* Waveform Seekbar */}
|
||||
<div className="player-waveform-section">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : 'inherit' }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* EQ Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||
onClick={() => setEqOpen(v => !v)}
|
||||
aria-label="Equalizer"
|
||||
data-tooltip="Equalizer"
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
|
||||
aria-label={t('player.volume')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
className="player-volume-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EQ Popup */}
|
||||
{eqOpen && (
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
|
||||
<div className="eq-popup">
|
||||
<div className="eq-popup-header">
|
||||
<span className="eq-popup-title">Equalizer</span>
|
||||
<button className="eq-popup-close" onClick={() => setEqOpen(false)} aria-label="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<Equalizer />
|
||||
<div className="player-progress">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-progress-bar">
|
||||
<input
|
||||
type="range"
|
||||
id="player-seek"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={progressStyle}
|
||||
aria-label={t('player.progress')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume + Connection */}
|
||||
<div className="player-right">
|
||||
<div className="volume-control" aria-label={t('player.volume')}>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={toggleQueue}
|
||||
aria-label={t('player.toggleQueue')}
|
||||
data-tooltip={t('player.toggleQueue')}
|
||||
style={{ marginLeft: 'var(--space-3)', color: isQueueVisible ? 'var(--accent)' : 'var(--text-secondary)' }}
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } from 'lucide-react';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -110,13 +109,8 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
|
||||
);
|
||||
}
|
||||
|
||||
// Module-level fallback for fromIdx — survives the dragend-before-drop race on
|
||||
// macOS WKWebView AND the dataTransfer.getData('') bug on Windows WebView2.
|
||||
let _dragFromIdx: number | null = null;
|
||||
|
||||
export default function QueuePanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
@@ -126,19 +120,11 @@ export default function QueuePanel() {
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
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);
|
||||
const isDraggingInternalRef = useRef(false);
|
||||
// Refs mirror state so drop handler always reads fresh values even when
|
||||
// macOS WKWebView fires dragend before drop (spec violation).
|
||||
const draggedIdxRef = useRef<number | null>(null);
|
||||
const dragOverIdxRef = useRef<number | null>(null);
|
||||
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
@@ -158,11 +144,12 @@ export default function QueuePanel() {
|
||||
|
||||
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 }));
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
type: 'queue_reorder',
|
||||
index
|
||||
}));
|
||||
};
|
||||
|
||||
const onDragEnterItem = (e: React.DragEvent) => {
|
||||
@@ -173,74 +160,60 @@ export default function QueuePanel() {
|
||||
const onDragOverItem = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
|
||||
dragOverIdxRef.current = index;
|
||||
setDragOverIdx(index);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
isDraggingInternalRef.current = false;
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
// _dragFromIdx intentionally NOT cleared here — drop fires after dragend on
|
||||
// macOS WKWebView, so we need the value to survive into onDropQueue.
|
||||
// It is cleared in onDropQueue after use instead.
|
||||
};
|
||||
|
||||
const onDropQueue = async (e: React.DragEvent) => {
|
||||
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear visual state immediately
|
||||
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 { /* ignore */ }
|
||||
|
||||
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
|
||||
// fromIdx: prefer dataTransfer value; fall back to module-level var for
|
||||
// Windows WebView2 where getData() can return '' in the drop handler.
|
||||
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
|
||||
_dragFromIdx = null;
|
||||
|
||||
// toIdx: calculate from drop coordinates — avoids all ref timing issues.
|
||||
// Works even when dragend fires before drop (macOS WKWebView / Windows WebView2).
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Handle internal queue reorder using state — more reliable than dataTransfer on Windows/WebView2
|
||||
if (draggedIdx !== null) {
|
||||
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
|
||||
if (draggedIdx !== targetIdx) {
|
||||
reorderQueue(draggedIdx, targetIdx);
|
||||
}
|
||||
|
||||
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
|
||||
isDraggingInternalRef.current = false;
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// External drop (song / album dragged from elsewhere in the app)
|
||||
_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);
|
||||
isDraggingInternalRef.current = false;
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
|
||||
try {
|
||||
const dataStr = e.dataTransfer.getData('text/plain');
|
||||
if (!dataStr) return;
|
||||
const data = JSON.parse(dataStr);
|
||||
|
||||
if (data.type === 'song') {
|
||||
const track = data.track;
|
||||
if (dropIndex !== undefined) {
|
||||
// If dropped on a specific item, we might want to insert it there.
|
||||
// For now we just enqueue it at the end to keep it simple, or insert it.
|
||||
// Since we don't have an insert method, we use enqueue (appends to end).
|
||||
enqueue([track]);
|
||||
} else {
|
||||
enqueue([track]);
|
||||
}
|
||||
} else if (data.type === 'album') {
|
||||
const albumData = await getAlbum(data.id);
|
||||
const tracks: Track[] = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Drop error', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,34 +222,15 @@ export default function QueuePanel() {
|
||||
className="queue-panel"
|
||||
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDrop={onDropQueue}
|
||||
onDrop={e => onDropQueue(e)}
|
||||
style={{
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<div className="queue-header">
|
||||
<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>
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
|
||||
<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}>
|
||||
<Shuffle size={14} />
|
||||
</button>
|
||||
<button onClick={handleSave} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.savePlaylist')} data-tooltip={t('queue.save')}>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
@@ -297,28 +251,16 @@ export default function QueuePanel() {
|
||||
<div className="queue-current-track">
|
||||
<div className="queue-current-cover">
|
||||
{currentTrack.coverArt ? (
|
||||
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
|
||||
<img src={buildCoverArtUrl(currentTrack.coverArt, 400)} alt="" loading="eager" />
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<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.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.album}</div>
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
{currentTrack.year && <div className="queue-current-sub">{currentTrack.year}</div>}
|
||||
<div className="queue-current-sub truncate" data-tooltip={currentTrack.album}>{currentTrack.album}</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix ? (
|
||||
@@ -335,7 +277,7 @@ export default function QueuePanel() {
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
|
||||
<div className="queue-list" ref={queueListRef}>
|
||||
<div className="queue-list">
|
||||
{queue.length === 0 ? (
|
||||
<div className="queue-empty">
|
||||
{t('queue.emptyQueue')}
|
||||
@@ -359,10 +301,9 @@ export default function QueuePanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -373,6 +314,10 @@ export default function QueuePanel() {
|
||||
onDragEnter={(e) => onDragEnterItem(e)}
|
||||
onDragOver={(e) => onDragOverItem(e, idx)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => {
|
||||
e.stopPropagation();
|
||||
onDropQueue(e, idx);
|
||||
}}
|
||||
style={dragStyle}
|
||||
>
|
||||
<div className="queue-item-info">
|
||||
@@ -413,7 +358,7 @@ export default function QueuePanel() {
|
||||
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,
|
||||
albumId: s.albumId, 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) {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
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 React from 'react';
|
||||
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, AudioLines
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
|
||||
<img src="/logo.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
|
||||
);
|
||||
|
||||
const navItems = [
|
||||
@@ -24,77 +21,14 @@ const navItems = [
|
||||
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
|
||||
];
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const parse = (v: string) => v.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
const [lMaj, lMin, lPat] = parse(latest);
|
||||
const [cMaj, cMin, cPat] = parse(current);
|
||||
if (lMaj !== cMaj) return lMaj > cMaj;
|
||||
if (lMin !== cMin) return lMin > cMin;
|
||||
return lPat > cPat;
|
||||
}
|
||||
|
||||
function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; latestVersion: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="update-toast-icon" style={{ marginTop: 'auto' }} title={`${t('sidebar.updateAvailable')}: ${latestVersion}`}>
|
||||
<ArrowUpCircle size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="update-toast">
|
||||
<div className="update-toast-header">
|
||||
<ArrowUpCircle size={14} />
|
||||
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
|
||||
</div>
|
||||
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
|
||||
<button
|
||||
className="update-toast-link"
|
||||
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases')}
|
||||
>
|
||||
{t('sidebar.updateLink')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
isCollapsed = false,
|
||||
toggleCollapse
|
||||
}: {
|
||||
export default function Sidebar({
|
||||
isCollapsed = false,
|
||||
toggleCollapse
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, appVersion)) {
|
||||
setLatestVersion(tag.replace(/^v/i, ''));
|
||||
}
|
||||
} catch {
|
||||
// network unavailable — silently skip
|
||||
}
|
||||
};
|
||||
|
||||
const initial = setTimeout(check, 1500);
|
||||
const interval = setInterval(check, 10 * 60 * 1000); // every 10 minutes
|
||||
|
||||
return () => { cancelled = true; clearTimeout(initial); clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
@@ -126,26 +60,11 @@ export default function Sidebar({
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* 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} />}
|
||||
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
|
||||
<NavLink
|
||||
to="/statistics"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
|
||||
style={isCollapsed ? { marginTop: 'auto' } : undefined}
|
||||
title={isCollapsed ? t('sidebar.statistics') : undefined}
|
||||
>
|
||||
<BarChart3 size={isCollapsed ? 22 : 18} />
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import butterchurn from 'butterchurn';
|
||||
import butterchurnPresets from 'butterchurn-presets';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
interface Props {
|
||||
trackId: string;
|
||||
nextPresetTrigger: number;
|
||||
onPresetName: (name: string) => void;
|
||||
}
|
||||
|
||||
export default function VisualizerCanvas({ trackId, nextPresetTrigger, onPresetName }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const vizRef = useRef<ReturnType<typeof butterchurn.createVisualizer> | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const presetNamesRef = useRef<string[]>([]);
|
||||
const presetMapRef = useRef<Record<string, unknown>>({});
|
||||
const presetIdxRef = useRef(0);
|
||||
|
||||
// ── Init audio analysis + butterchurn ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Hidden audio element — routed through Web Audio for analysis only.
|
||||
// NOT connected to AudioDestinationNode → completely silent.
|
||||
// The Rust/rodio engine plays the actual audio.
|
||||
const streamUrl = buildStreamUrl(trackId);
|
||||
const audio = new Audio(streamUrl);
|
||||
audio.crossOrigin = 'anonymous';
|
||||
audioRef.current = audio;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
audioCtxRef.current = ctx;
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
// Intentionally no: source.connect(ctx.destination)
|
||||
|
||||
// Size canvas to fill its container
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || window.innerWidth;
|
||||
const h = rect.height || window.innerHeight;
|
||||
canvas.width = w * (window.devicePixelRatio || 1);
|
||||
canvas.height = h * (window.devicePixelRatio || 1);
|
||||
|
||||
const visualizer = butterchurn.createVisualizer(ctx, canvas, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
pixelRatio: window.devicePixelRatio || 1,
|
||||
});
|
||||
vizRef.current = visualizer;
|
||||
visualizer.connectAudio(source);
|
||||
|
||||
// Presets
|
||||
const presets = butterchurnPresets.getPresets();
|
||||
const names = Object.keys(presets);
|
||||
presetNamesRef.current = names;
|
||||
presetMapRef.current = presets;
|
||||
const startIdx = Math.floor(Math.random() * names.length);
|
||||
presetIdxRef.current = startIdx;
|
||||
visualizer.loadPreset(presets[names[startIdx]], 2.0);
|
||||
onPresetName(names[startIdx]);
|
||||
|
||||
// Sync position + play state with main player
|
||||
const { currentTime, isPlaying } = usePlayerStore.getState();
|
||||
audio.currentTime = currentTime;
|
||||
if (isPlaying) audio.play().catch(() => {});
|
||||
|
||||
// Render loop
|
||||
const render = () => {
|
||||
rafRef.current = requestAnimationFrame(render);
|
||||
visualizer.render();
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(render);
|
||||
|
||||
// Keep canvas sized to window
|
||||
const onResize = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = r.width * dpr;
|
||||
canvas.height = r.height * dpr;
|
||||
visualizer.setRendererSize(canvas.width, canvas.height);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Sync play/pause with main player
|
||||
const unsubscribe = usePlayerStore.subscribe(state => {
|
||||
const a = audioRef.current;
|
||||
const c = audioCtxRef.current;
|
||||
if (!a || !c) return;
|
||||
if (state.isPlaying) {
|
||||
c.resume();
|
||||
a.play().catch(() => {});
|
||||
} else {
|
||||
a.pause();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', onResize);
|
||||
unsubscribe();
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
ctx.close();
|
||||
vizRef.current = null;
|
||||
};
|
||||
}, [trackId]); // re-init on track change
|
||||
|
||||
// ── Next preset ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!nextPresetTrigger) return;
|
||||
const viz = vizRef.current;
|
||||
const names = presetNamesRef.current;
|
||||
if (!viz || names.length === 0) return;
|
||||
const next = (presetIdxRef.current + 1) % names.length;
|
||||
presetIdxRef.current = next;
|
||||
viz.loadPreset(presetMapRef.current[names[next]], 2.0);
|
||||
onPresetName(names[next]);
|
||||
}, [nextPresetTrigger]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
const BAR_COUNT = 500;
|
||||
|
||||
function hashStr(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h ^ str.charCodeAt(i)) >>> 0;
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function makeHeights(trackId: string): Float32Array {
|
||||
let s = hashStr(trackId);
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
h[i] = s / 0xffffffff;
|
||||
}
|
||||
// Smooth for an organic look
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
for (let i = 1; i < BAR_COUNT - 1; i++) {
|
||||
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
|
||||
}
|
||||
}
|
||||
// Normalize to [0.12, 1.0]
|
||||
let max = 0;
|
||||
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
|
||||
if (max > 0) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || canvas.clientWidth;
|
||||
const h = rect.height || canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa';
|
||||
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
|
||||
if (!heights) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
ctx.fillRect(0, (h - 2) / 2, w, 2);
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
|
||||
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
||||
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
||||
|
||||
// Pass 1 — unplayed (dim)
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT < buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
|
||||
// Pass 2 — buffered (slightly brighter)
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = colorBuffered;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < progress || frac >= buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
|
||||
// Pass 3 — played (gradient + glow)
|
||||
if (progress > 0) {
|
||||
const grad = ctx.createLinearGradient(0, 0, progress * w, 0);
|
||||
grad.addColorStop(0, colorBlue);
|
||||
grad.addColorStop(1, colorMauve);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = grad;
|
||||
ctx.shadowColor = colorMauve;
|
||||
ctx.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trackId: string | undefined;
|
||||
}
|
||||
|
||||
export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
progressRef.current = progress;
|
||||
bufferedRef.current = buffered;
|
||||
|
||||
useEffect(() => {
|
||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||
}, [trackId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered);
|
||||
}
|
||||
}, [progress, buffered, trackId]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const trackIdRef = useRef(trackId);
|
||||
trackIdRef.current = trackId;
|
||||
const seekRef = useRef(seek);
|
||||
seekRef.current = seek;
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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,16 +13,12 @@ const enTranslation = {
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Random Mix',
|
||||
favorites: 'Favorites',
|
||||
nowPlaying: 'Now Playing',
|
||||
system: 'System',
|
||||
statistics: 'Statistics',
|
||||
settings: 'Settings',
|
||||
help: 'Help',
|
||||
expand: 'Expand Sidebar',
|
||||
collapse: 'Collapse Sidebar',
|
||||
updateAvailable: 'Update available',
|
||||
updateReady: '{{version}} is ready',
|
||||
updateLink: 'Go to release →'
|
||||
collapse: 'Collapse Sidebar'
|
||||
},
|
||||
home: {
|
||||
starred: 'Personal Favorites',
|
||||
@@ -45,9 +41,6 @@ const enTranslation = {
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
clearLabel: 'Clear search',
|
||||
title: 'Search',
|
||||
resultsFor: 'Results for "{{query}}"',
|
||||
album: 'Album',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Who is listening?',
|
||||
@@ -55,7 +48,6 @@ const enTranslation = {
|
||||
loading: 'Loading…',
|
||||
nobody: 'Nobody is currently listening.',
|
||||
minutesAgo: '{{n}}m ago',
|
||||
nothingPlaying: 'Nothing playing yet. Start a track!',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Play Now',
|
||||
@@ -122,8 +114,6 @@ const enTranslation = {
|
||||
favorite: 'Favorite',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Albums',
|
||||
openedInBrowser: 'Opened in browser',
|
||||
featuredOn: 'Also Featured On',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
@@ -131,7 +121,6 @@ const enTranslation = {
|
||||
artists: 'Artists',
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
enqueueAll: 'Add all to queue',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
@@ -150,19 +139,6 @@ const enTranslation = {
|
||||
favoriteAdd: 'Add to Favorites',
|
||||
favoriteRemove: 'Remove from Favorites',
|
||||
play: 'Play',
|
||||
trackGenre: 'Genre',
|
||||
excludeAudiobooks: 'Exclude audiobooks & radio plays',
|
||||
excludeAudiobooksDesc: 'Matches keywords against genre, title, and album — e.g. Hörbuch, Audiobook, Spoken Word, …',
|
||||
genreBlocked: 'Keyword blocked',
|
||||
genreAddedToBlacklist: 'Added to filter list',
|
||||
genreAlreadyBlocked: 'Already blocked',
|
||||
blacklistToggle: 'Keyword Filter',
|
||||
genreMixTitle: 'Genre Mix',
|
||||
genreMixDesc: 'Select a genre to get a curated random mix',
|
||||
genreMixLoadMore: 'Load 10 more',
|
||||
genreMixNoGenres: 'No genres found on server.',
|
||||
filterPanelTitle: 'Filters',
|
||||
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title & album.',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
@@ -174,11 +150,6 @@ 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',
|
||||
@@ -217,15 +188,6 @@ 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',
|
||||
@@ -242,10 +204,6 @@ const enTranslation = {
|
||||
use: 'Use',
|
||||
add: 'Add',
|
||||
active: 'Active',
|
||||
download: 'Download',
|
||||
chooseDownloadFolder: 'Choose download folder',
|
||||
noFolderSelected: 'No folder selected',
|
||||
rememberDownloadFolder: 'Remember this folder',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -273,16 +231,6 @@ 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',
|
||||
@@ -299,41 +247,15 @@ const enTranslation = {
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
pickFolderTitle: 'Select Download Folder',
|
||||
clearFolder: 'Clear download folder',
|
||||
logout: 'Logout',
|
||||
aboutTitle: 'About Psysonic',
|
||||
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
|
||||
aboutFeatures: 'Multi-server support · Scrobbling · Fullscreen player · Album downloads · Image caching · Catppuccin themes',
|
||||
aboutLicense: 'License',
|
||||
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
|
||||
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
|
||||
aboutRepo: 'Source Code on GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
changelog: 'Changelog',
|
||||
randomMixTitle: 'Random Mix',
|
||||
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
||||
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).',
|
||||
randomMixBlacklistPlaceholder: 'Add keyword…',
|
||||
randomMixBlacklistAdd: 'Add',
|
||||
randomMixBlacklistEmpty: 'No custom keywords added yet.',
|
||||
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAppearance: 'Appearance',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'About',
|
||||
playbackTitle: 'Playback',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
|
||||
replayGainMode: 'Mode',
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js'
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
@@ -350,7 +272,7 @@ const enTranslation = {
|
||||
q5: 'What keyboard shortcuts are available?',
|
||||
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on macOS and Windows. On Linux, use the player bar buttons.',
|
||||
q6: 'What is the queue?',
|
||||
a6: 'The queue shows all upcoming tracks. Open it with the panel icon in the top-right header (next to the Now Playing indicator). You can reorder tracks by dragging, shuffle with the shuffle button, and save the queue as a playlist.',
|
||||
a6: 'The queue shows all upcoming tracks. Open it with the list icon in the player bar. You can reorder tracks by dragging and save the current queue as a playlist.',
|
||||
q7: 'How do I open the fullscreen player?',
|
||||
a7: 'Click the album art thumbnail in the player bar at the bottom, or the expand icon next to it. Press Escape to close it again.',
|
||||
q8: 'How does repeat work?',
|
||||
@@ -376,21 +298,6 @@ const enTranslation = {
|
||||
a16: 'Psysonic uses your Navidrome server\'s built-in Last.fm integration. First, connect your Last.fm account in Navidrome\'s web interface. Then enable scrobbling in Psysonic\'s Settings.',
|
||||
q17: 'When is a scrobble sent?',
|
||||
a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.',
|
||||
q22: 'What is the waveform in the player bar?',
|
||||
a22: 'The waveform seekbar replaces the classic progress slider. Click anywhere on it to jump to that position, or drag to scrub. The played portion glows with a blue-to-mauve gradient, the buffered range appears slightly brighter, and the unplayed portion is faded.',
|
||||
q23: 'How do I use the MilkDrop visualizer?',
|
||||
a23: 'Open the fullscreen player (click the album art in the player bar), then click the waveform icon in the top-right corner. The visualizer starts with a random MilkDrop preset. Use the shuffle button next to it to cycle through hundreds of presets.',
|
||||
q24: 'Can I shuffle the queue?',
|
||||
a24: 'Yes. Open the queue panel and click the shuffle icon in the queue header. The currently playing track stays at position 1 — all remaining tracks are randomly reordered.',
|
||||
q25: 'Do Last.fm and Wikipedia links on artist pages open in a browser?',
|
||||
a25: 'No — they open in a dedicated in-app window so you never leave Psysonic. The window shows the full website and can be closed independently.',
|
||||
s7: 'Random Mix',
|
||||
q26: 'What is Random Mix?',
|
||||
a26: 'Random Mix builds a playlist of random tracks from your entire library. Open it via "Random Mix" in the sidebar and click "New Mix". You can adjust the track count before generating.',
|
||||
q27: 'What is the Keyword Filter?',
|
||||
a27: 'The Keyword Filter excludes tracks whose genre, title, or album name contains specific words. Audiobooks are filtered automatically. Add custom keywords in Settings → Random Mix, or click any genre tag in the track list to add it instantly.',
|
||||
q28: 'What is the Super Genre Mix?',
|
||||
a28: 'The Super Genre Mix groups your library into broad categories (Rock, Metal, Electronic, Jazz, Classical, etc.) and builds a focused mix from that style. Select a category chip below the track list. Results appear progressively as each sub-genre is fetched.',
|
||||
s6: 'Troubleshooting',
|
||||
q18: 'Cover art and artist images load slowly.',
|
||||
a18: 'Images are fetched from your server\'s disk on first load and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page may take a moment. Subsequent visits will be instant.',
|
||||
@@ -414,40 +321,23 @@ const enTranslation = {
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Delete playlist "{{name}}"?',
|
||||
clear: 'Clear',
|
||||
shuffle: 'Shuffle queue',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
emptyQueue: 'The queue is empty.',
|
||||
trackSingular: 'track',
|
||||
trackPlural: 'tracks',
|
||||
emptyQueue: 'The queue is empty.'
|
||||
},
|
||||
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',
|
||||
openFullscreen: 'Open Fullscreen Player',
|
||||
fullscreen: 'Fullscreen Player',
|
||||
closeFullscreen: 'Close Fullscreen',
|
||||
visualizer: 'Visualizer',
|
||||
nextPreset: 'Next preset',
|
||||
closeTooltip: 'Close (Esc)',
|
||||
noTitle: 'No Title',
|
||||
stop: 'Stop',
|
||||
@@ -477,16 +367,12 @@ const deTranslation = {
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Zufallsmix',
|
||||
favorites: 'Favoriten',
|
||||
nowPlaying: 'Now Playing',
|
||||
system: 'System',
|
||||
statistics: 'Statistiken',
|
||||
settings: 'Einstellungen',
|
||||
help: 'Hilfe',
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden',
|
||||
updateAvailable: 'Update verfügbar',
|
||||
updateReady: '{{version}} ist bereit',
|
||||
updateLink: 'Zum Release →'
|
||||
collapse: 'Sidebar ausblenden'
|
||||
},
|
||||
home: {
|
||||
starred: 'Persönliche Favoriten',
|
||||
@@ -509,9 +395,6 @@ const deTranslation = {
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
clearLabel: 'Suche leeren',
|
||||
title: 'Suche',
|
||||
resultsFor: 'Ergebnisse für „{{query}}"',
|
||||
album: 'Album',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Wer hört was?',
|
||||
@@ -519,7 +402,6 @@ 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',
|
||||
@@ -586,8 +468,6 @@ const deTranslation = {
|
||||
favorite: 'Als Favorit',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Alben',
|
||||
openedInBrowser: 'Im Browser geöffnet',
|
||||
featuredOn: 'Auch enthalten auf',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
@@ -595,7 +475,6 @@ const deTranslation = {
|
||||
artists: 'Künstler',
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
enqueueAll: 'Alle in die Warteschlange',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
@@ -614,19 +493,6 @@ const deTranslation = {
|
||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||
favoriteRemove: 'Aus Favoriten entfernen',
|
||||
play: 'Abspielen',
|
||||
trackGenre: 'Genre',
|
||||
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
|
||||
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel und Album — z. B. Hörbuch, Audiobook, Spoken Word, …',
|
||||
genreBlocked: 'Keyword gesperrt',
|
||||
genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
|
||||
genreAlreadyBlocked: 'Bereits gesperrt',
|
||||
blacklistToggle: 'Keyword-Filter',
|
||||
genreMixTitle: 'Genre-Mix',
|
||||
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
|
||||
genreMixLoadMore: '10 weitere laden',
|
||||
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
|
||||
filterPanelTitle: 'Filter',
|
||||
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel & Album.',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
@@ -638,11 +504,6 @@ 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',
|
||||
@@ -681,15 +542,6 @@ 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',
|
||||
@@ -706,10 +558,6 @@ const deTranslation = {
|
||||
use: 'Verwenden',
|
||||
add: 'Hinzufügen',
|
||||
active: 'Aktiv',
|
||||
download: 'Herunterladen',
|
||||
chooseDownloadFolder: 'Download-Ordner wählen',
|
||||
noFolderSelected: 'Kein Ordner ausgewählt',
|
||||
rememberDownloadFolder: 'Ordner merken',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -737,16 +585,6 @@ 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',
|
||||
@@ -763,41 +601,15 @@ const deTranslation = {
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
pickFolderTitle: 'Download-Ordner auswählen',
|
||||
clearFolder: 'Download-Ordner löschen',
|
||||
logout: 'Abmelden',
|
||||
aboutTitle: 'Über Psysonic',
|
||||
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
|
||||
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
|
||||
aboutLicense: 'Lizenz',
|
||||
aboutLicenseText: 'GNU GPL v3 — kostenlos nutzbar, veränderbar und unter gleicher Lizenz weiterzugeben.',
|
||||
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weitergabbar.',
|
||||
aboutRepo: 'Quellcode auf GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||
changelog: 'Changelog',
|
||||
randomMixTitle: 'Zufallsmix',
|
||||
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
||||
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).',
|
||||
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
|
||||
randomMixBlacklistAdd: 'Hinzufügen',
|
||||
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
|
||||
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAppearance: 'Darstellung',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'Info',
|
||||
playbackTitle: 'Wiedergabe',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren',
|
||||
replayGainMode: 'Modus',
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js'
|
||||
},
|
||||
help: {
|
||||
title: 'Hilfe',
|
||||
@@ -814,7 +626,7 @@ const deTranslation = {
|
||||
q5: 'Welche Tastenkürzel gibt es?',
|
||||
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren unter macOS und Windows. Unter Linux bitte die Buttons in der Playerleiste nutzen.',
|
||||
q6: 'Was ist die Warteschlange?',
|
||||
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Panel-Icon oben rechts im Header (neben dem Now-Playing-Indikator). Tracks per Drag & Drop umsortieren, mit dem Shuffle-Button mischen oder als Playlist speichern.',
|
||||
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Listen-Icon in der Playerleiste. Tracks können per Drag & Drop umsortiert und als Playlist gespeichert werden.',
|
||||
q7: 'Wie öffne ich den Vollbild-Player?',
|
||||
a7: 'Klick auf das Album-Cover unten in der Playerleiste oder auf das Expand-Icon daneben. Mit Escape wieder schließen.',
|
||||
q8: 'Wie funktioniert die Wiederholfunktion?',
|
||||
@@ -840,21 +652,6 @@ const deTranslation = {
|
||||
a16: 'Psysonic nutzt die eingebaute Last.fm-Integration von Navidrome. Zuerst das Last.fm-Konto im Navidrome-Webinterface verbinden, dann Scrobbling in den Psysonic-Einstellungen aktivieren.',
|
||||
q17: 'Wann wird ein Scrobble gesendet?',
|
||||
a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.',
|
||||
q22: 'Was ist die Waveform in der Playerleiste?',
|
||||
a22: 'Die Waveform-Leiste ersetzt den klassischen Fortschrittsbalken. Klick auf eine beliebige Stelle zum Springen, ziehen zum Scrubben. Der gespielte Teil leuchtet in einem Blau-Mauve-Farbverlauf, der gepufferte Bereich erscheint etwas heller, der ungespielter Teil ist abgeblendet.',
|
||||
q23: 'Wie benutze ich den MilkDrop-Visualizer?',
|
||||
a23: 'Vollbild-Player öffnen (Klick auf das Album-Cover in der Playerleiste), dann oben rechts auf das Waveform-Icon klicken. Der Visualizer startet mit einem zufälligen MilkDrop-Preset. Mit dem Shuffle-Button daneben durch Hunderte von Presets wechseln.',
|
||||
q24: 'Kann ich die Warteschlange mischen?',
|
||||
a24: 'Ja. Die Warteschlange öffnen und auf das Shuffle-Icon im Header klicken. Der aktuell laufende Track bleibt an Position 1 — alle restlichen Tracks werden zufällig neu geordnet.',
|
||||
q25: 'Öffnen Last.fm- und Wikipedia-Links auf Künstlerseiten im Browser?',
|
||||
a25: 'Nein — sie öffnen sich in einem eigenen In-App-Fenster, sodass du Psysonic nie verlassen musst. Das Fenster zeigt die vollständige Website und kann unabhängig geschlossen werden.',
|
||||
s7: 'Zufallsmix',
|
||||
q26: 'Was ist der Zufallsmix?',
|
||||
a26: 'Der Zufallsmix erstellt eine Playlist aus zufälligen Tracks deiner gesamten Bibliothek. Über "Zufallsmix" in der Seitenleiste erreichbar, dann "Neuer Mix" klicken. Die Anzahl der Tracks ist vor der Generierung einstellbar.',
|
||||
q27: 'Was ist der Keyword-Filter?',
|
||||
a27: 'Der Keyword-Filter schließt Tracks aus, deren Genre, Titel oder Album bestimmte Wörter enthält. Hörbücher werden automatisch gefiltert. Eigene Keywords in Einstellungen → Zufallsmix hinzufügen oder per Klick auf ein Genre-Tag direkt in der Trackliste.',
|
||||
q28: 'Was ist der Super-Genre-Mix?',
|
||||
a28: 'Der Super-Genre-Mix fasst die Bibliothek in übergeordnete Kategorien zusammen (Rock, Metal, Electronic, Jazz, Klassik usw.) und erstellt daraus einen fokussierten Mix. Einen Kategorie-Chip unterhalb der Trackliste auswählen. Ergebnisse erscheinen schrittweise, während die einzelnen Sub-Genres geladen werden.',
|
||||
s6: 'Problemlösung',
|
||||
q18: 'Cover und Künstlerbilder laden langsam.',
|
||||
a18: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern. Danach geht alles sofort.',
|
||||
@@ -878,40 +675,23 @@ const deTranslation = {
|
||||
delete: 'Löschen',
|
||||
deleteConfirm: 'Playlist "{{name}}" löschen?',
|
||||
clear: 'Leeren',
|
||||
shuffle: 'Warteschlange mischen',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
emptyQueue: 'Die Warteschlange ist leer.',
|
||||
trackSingular: 'Titel',
|
||||
trackPlural: 'Titel',
|
||||
emptyQueue: 'Die Warteschlange ist leer.'
|
||||
},
|
||||
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',
|
||||
openFullscreen: 'Vollbild-Player öffnen',
|
||||
fullscreen: 'Vollbild-Player',
|
||||
closeFullscreen: 'Vollbild schließen',
|
||||
visualizer: 'Visualizer',
|
||||
nextPreset: 'Nächstes Preset',
|
||||
closeTooltip: 'Schließen (Esc)',
|
||||
noTitle: 'Kein Titel',
|
||||
stop: 'Stop',
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { 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 AlbumHeader from '../components/AlbumHeader';
|
||||
import AlbumTrackList from '../components/AlbumTrackList';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
@@ -20,18 +20,87 @@ 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; samplingRate?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
if (song.samplingRate) parts.push(`${(song.samplingRate / 1000).toFixed(1)} kHz`);
|
||||
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 }>();
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
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>>({});
|
||||
@@ -41,7 +110,6 @@ export default function AlbumDetail() {
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -67,8 +135,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, 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);
|
||||
};
|
||||
@@ -77,8 +145,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, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
};
|
||||
@@ -86,8 +154,9 @@ export default function AlbumDetail() {
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
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,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating
|
||||
};
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
@@ -105,14 +174,7 @@ export default function AlbumDetail() {
|
||||
setBioOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!album) return;
|
||||
const { name, id: albumId } = album.album;
|
||||
|
||||
// Ask for folder before starting download if not already set
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
const handleDownload = async (albumName: string, albumId: string) => {
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
@@ -140,38 +202,51 @@ export default function AlbumDetail() {
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${sanitizeFilename(albumName)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
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 wasStarred = isStarred;
|
||||
setIsStarred(!wasStarred);
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred);
|
||||
try {
|
||||
if (wasStarred) await unstar(album.album.id);
|
||||
if (currentlyStarred) await unstar(album.album.id);
|
||||
else await star(album.album.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(wasStarred);
|
||||
setIsStarred(currentlyStarred);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const wasStarred = starredSongs.has(song.id);
|
||||
const next = new Set(starredSongs);
|
||||
if (wasStarred) next.delete(song.id); else next.add(song.id);
|
||||
setStarredSongs(next);
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
try {
|
||||
if (wasStarred) await unstar(song.id, 'song');
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
@@ -188,47 +263,221 @@ 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">
|
||||
<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)}
|
||||
/>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
|
||||
|
||||
<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}
|
||||
/>
|
||||
<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>{t('albumDetail.trackFormat')}</div>
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
|
||||
<div>{t('albumDetail.trackRating')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</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' : ''}`}
|
||||
onDoubleClick={() => handlePlaySong(song)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, 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, 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' }}>{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-meta" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec" style={{ marginTop: 0 }}>
|
||||
{codecLabel(song)}
|
||||
{song.size ? <span className="track-size"> · {formatSize(song.size)}</span> : null}
|
||||
</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: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</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>
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
<div className="album-related">
|
||||
<div className="album-related-divider" />
|
||||
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||
<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-grid-wrap">
|
||||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
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';
|
||||
@@ -45,13 +45,11 @@ export default function ArtistDetail() {
|
||||
const navigate = useNavigate();
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
@@ -61,45 +59,17 @@ export default function ArtistDetail() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
let ownAlbumIds: Set<string>;
|
||||
getArtist(id).then(artistData => {
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
ownAlbumIds = new Set(artistData.albums.map(a => a.id));
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => []),
|
||||
search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })),
|
||||
getTopSongs(artistData.artist.name).catch(() => [])
|
||||
]);
|
||||
}).then(([artistInfo, songsData, searchResults]) => {
|
||||
}).then(([artistInfo, songsData]) => {
|
||||
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
|
||||
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
|
||||
|
||||
const featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
|
||||
);
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
albumMap.set(song.albumId, {
|
||||
id: song.albumId,
|
||||
name: song.album,
|
||||
artist: song.albumArtist ?? '',
|
||||
artistId: '',
|
||||
coverArt: song.coverArt,
|
||||
songCount: 1,
|
||||
duration: song.duration,
|
||||
year: song.year,
|
||||
});
|
||||
} else {
|
||||
const a = albumMap.get(song.albumId)!;
|
||||
a.songCount++;
|
||||
a.duration += song.duration;
|
||||
}
|
||||
});
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
@@ -107,11 +77,7 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
setOpenedLink(key);
|
||||
setTimeout(() => setOpenedLink(null), 2500);
|
||||
};
|
||||
const openLink = (url: string) => open(url);
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
@@ -217,14 +183,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!, 'lastfm')}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!)}>
|
||||
<LastfmIcon size={14} />
|
||||
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
|
||||
Last.fm
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl)}>
|
||||
<ExternalLink size={14} />
|
||||
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
||||
Wikipedia
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -292,7 +258,7 @@ export default function ArtistDetail() {
|
||||
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,
|
||||
albumId: song.albumId, 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, 'song');
|
||||
@@ -337,18 +303,6 @@ export default function ArtistDetail() {
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||
)}
|
||||
|
||||
{/* Also Featured On */}
|
||||
{featuredAlbums.length > 0 && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.featuredOn')}
|
||||
</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function Artists() {
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
|
||||
@@ -3,8 +3,7 @@ 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, ListPlus } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Favorites() {
|
||||
@@ -14,9 +13,7 @@ export default function Favorites() {
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const navigate = useNavigate();
|
||||
const { playTrack } = usePlayerStore();
|
||||
|
||||
useEffect(() => {
|
||||
getStarred()
|
||||
@@ -59,68 +56,44 @@ export default function Favorites() {
|
||||
|
||||
{songs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<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 className="album-row-header" style={{ marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('favorites.songs')}</h2>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
<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 }));
|
||||
}}
|
||||
{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, 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="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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle } from 'lucide-react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FaqItem { q: string; a: string; }
|
||||
@@ -42,9 +42,6 @@ export default function Help() {
|
||||
{ q: t('help.q6'), a: t('help.a6') },
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
{ q: t('help.q22'), a: t('help.a22') },
|
||||
{ q: t('help.q23'), a: t('help.a23') },
|
||||
{ q: t('help.q24'), a: t('help.a24') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -54,7 +51,6 @@ export default function Help() {
|
||||
{ q: t('help.q9'), a: t('help.a9') },
|
||||
{ q: t('help.q10'), a: t('help.a10') },
|
||||
{ q: t('help.q11'), a: t('help.a11') },
|
||||
{ q: t('help.q25'), a: t('help.a25') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -75,15 +71,6 @@ export default function Help() {
|
||||
{ q: t('help.q17'), a: t('help.a17') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Shuffle size={18} />,
|
||||
title: t('help.s7'),
|
||||
items: [
|
||||
{ q: t('help.q26'), a: t('help.a26') },
|
||||
{ q: t('help.q27'), a: t('help.a27') },
|
||||
{ q: t('help.q28'), a: t('help.a28') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Wrench size={18} />,
|
||||
title: t('help.s6'),
|
||||
|
||||